import lxml.html from datetime import timedelta from django.test import TestCase from django.urls import reverse from django.utils import timezone from apps.applets.models import Applet, UserApplet from apps.epic.models import DeckVariant, Room, TableSeat from apps.lyric.models import Token, User class GameboardViewTest(TestCase): def setUp(self): self.user = User.objects.create(email="gamer@test.io") self.client.force_login(self.user) Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"}) Applet.objects.get_or_create(slug="my-games", defaults={"name": "My Games", "context": "gameboard"}) Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"}) response = self.client.get("/gameboard/") self.parsed = lxml.html.fromstring(response.content) def test_gameboard_requires_login(self): self.client.logout() response = self.client.get("/gameboard/") self.assertRedirects( response, "/?next=/gameboard/", fetch_redirect_response=False ) def test_gameboard_renders(self): response = self.client.get("/gameboard/") self.assertEqual(response.status_code, 200) def test_gameboard_shows_my_games_applet(self): [_] = self.parsed.cssselect("#id_applet_my_games") def test_gameboard_shows_new_game_applet(self): [_] = self.parsed.cssselect("#id_applet_new_game") def test_gameboard_shows_my_sea_applet(self): # Sprint 3 of the My Sea roadmap — applet shell only; sigs/sea/draw # flow lands in later sprints. Seeded via migration 0008. [_] = self.parsed.cssselect("#id_applet_my_sea") def test_my_sea_applet_fires_sign_gate_brief_for_user_without_sig(self): # Sprint 4b (refactored 2026-05-22) — user with no significator # gets a Look!-formatted Brief banner (`Brief.showBanner` script # fired in the applet template) AND the applet body falls through # to the empty-state "No draws yet" (no sig → no draws is the only # possible state). The Brief itself renders client-side via JS so # we assert the script content + the FYI url, not the DOM banner. html = self.parsed.text_content() if False else \ lxml.html.tostring(self.parsed, encoding="unicode") self.assertIn("Look!", html) self.assertIn("pick your sign before drawing the Sea", html) self.assertIn("Brief.showBanner", html) # FYI url baked into the Brief script's `post_url` self.assertIn("/billboard/my-sign/", html) # Old inline gate markup is gone self.assertEqual( len(self.parsed.cssselect(".my-sea-sign-gate")), 0, ) # Card cells suppressed (no active draw possible without sig) self.assertEqual( len(self.parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled")), 0, ) def test_my_sea_applet_renders_empty_state_for_user_with_sig_no_draws(self): # Sig set + no saved draws → the scroll container hosts a single # placeholder line ("No draws yet."), no card cells, no gate Brief. from apps.epic.models import personal_sig_cards sig_pile = personal_sig_cards(self.user) self.user.significator = sig_pile[0] self.user.save() response = self.client.get("/gameboard/") parsed = lxml.html.fromstring(response.content) [empty] = parsed.cssselect("#id_applet_my_sea .my-sea-empty") self.assertIn("No draws yet", empty.text_content()) self.assertEqual( len(parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled")), 0, ) # No sign-gate Brief script fires when the user already has a sig html = lxml.html.tostring(parsed, encoding="unicode") self.assertNotIn("pick your sign before drawing the Sea", html) def test_my_sea_applet_header_links_to_my_sea_page(self): [link] = self.parsed.cssselect("#id_applet_my_sea h2 a") self.assertEqual(link.get("href"), reverse("my_sea")) def test_my_sea_applet_renders_drawn_cards_in_draw_order(self): """User w. a partial SAO draw — applet renders 3 slots in DRAW_ ORDER (lay, cover, crown → labelled Beneath/Cover/Crown? no — SAO labels are Situation/Action/Outcome). Drawn slot 1 (`lay`) carries the card; the un-drawn `cover` + `crown` slots are empty placeholders w. their per-spread labels.""" from apps.epic.models import personal_sig_cards, TarotCard from apps.gameboard.models import MySeaDraw sig_pile = personal_sig_cards(self.user) self.user.significator = sig_pile[0] self.user.save() card = TarotCard.objects.first() MySeaDraw.objects.create( user=self.user, spread="situation-action-outcome", hand=[ {"position": "lay", "card_id": card.id, "reversed": False, "polarity": "gravity"}, ], significator_id=self.user.significator_id, ) response = self.client.get("/gameboard/") parsed = lxml.html.fromstring(response.content) # All 3 SAO positions render in DRAW_ORDER (lay, cover, crown). wraps = parsed.cssselect("#id_applet_my_sea .my-sea-slot-wrap") self.assertEqual(len(wraps), 3, "SAO has 3 positions — applet should render 3 slot wraps") # Position 1 (`lay`) is filled w. the drawn card. filled = parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled") self.assertEqual(len(filled), 1) self.assertEqual( filled[0].get("data-position"), "lay", "First drawn card should land in the `lay` slug slot", ) self.assertEqual( filled[0].get("data-card-id"), str(card.id), ) # Positions 2 + 3 (cover, crown) are empty placeholders. empties = parsed.cssselect("#id_applet_my_sea .my-sea-slot--empty") self.assertEqual(len(empties), 2) empty_positions = {e.get("data-position") for e in empties} self.assertEqual(empty_positions, {"cover", "crown"}) def test_my_sea_applet_slot_renders_image_when_deck_has_card_images(self): """Sprint A.7 — when the drawn card belongs to an image-equipped deck (Minchiate today), the .my-sea-slot--filled carries `.my-sea-slot--image` + renders an child instead of the text scaffold. Shares the contour-stroke + depth shadow SCSS w. my_sign + my-sea central sig + my-sign-applet via the comma-list selector in `_card-deck.scss`.""" from apps.epic.models import personal_sig_cards, TarotCard, DeckVariant from apps.gameboard.models import MySeaDraw sig_pile = personal_sig_cards(self.user) self.user.significator = sig_pile[0] self.user.save() minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890") # Use a Minchiate card for the slot draw (doesn't need to be the sig # — the sig is from Earthman; the drawn card is a separate concept). card = TarotCard.objects.filter( deck_variant=minchiate, slug="il-matto", ).first() MySeaDraw.objects.create( user=self.user, spread="situation-action-outcome", hand=[{"position": "lay", "card_id": card.id, "reversed": False, "polarity": "gravity"}], significator_id=self.user.significator_id, ) response = self.client.get("/gameboard/") parsed = lxml.html.fromstring(response.content) [filled] = parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled") self.assertIn("my-sea-slot--image", filled.get("class", "")) self.assertEqual(filled.get("data-arcana-key"), "MAJOR") [img] = filled.cssselect("img.sig-stage-card-img") self.assertIn( "minchiate-fiorentine-1860-1890-trumps-00-il-matto.png", img.get("src", ""), ) # Text scaffold absent in image mode. self.assertEqual( len(filled.cssselect(".fan-card-corner")), 0, "Image-mode slot must not render the text scaffold", ) def test_my_sea_applet_renders_slots_even_when_user_significator_cleared(self): """Regression (user bug 2026-05-25 PM): deleting User.significator (via my-sign DEL) must NOT blank the My Sea applet on the gameboard when the user has saved MySeaDraw slots. The MySeaDraw row carries its own significator_id snapshot at first-draw-time so the saved draw is durable even after the user clears their sig. Applet display is now decoupled from `request.user.significator_id` — the sig-gate Brief banner only fires for users with NO draws AND no sig.""" from apps.epic.models import personal_sig_cards, TarotCard from apps.gameboard.models import MySeaDraw sig_pile = personal_sig_cards(self.user) snapshot_sig = sig_pile[0] card = TarotCard.objects.first() MySeaDraw.objects.create( user=self.user, spread="situation-action-outcome", hand=[ {"position": "lay", "card_id": card.id, "reversed": False, "polarity": "gravity"}, ], significator_id=snapshot_sig.id, ) # User clears their sig AFTER the draw was saved (the bug repro). self.user.significator = None self.user.significator_reversed = False self.user.save(update_fields=["significator", "significator_reversed"]) self.assertIsNone(self.user.significator_id) response = self.client.get("/gameboard/") parsed = lxml.html.fromstring(response.content) # Slots render despite no current sig — the MySeaDraw row owns the # display, not the user's live sig state. filled = parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled") self.assertEqual( len(filled), 1, "Filled slot must persist even when User.significator_id is None", ) self.assertEqual(filled[0].get("data-card-id"), str(card.id)) # And the "No draws yet" empty state must NOT render. empties = parsed.cssselect("#id_applet_my_sea .my-sea-empty") self.assertEqual( len(empties), 0, "Applet must not render 'No draws yet' when slots exist", ) def test_my_sea_applet_labels_match_locked_spread(self): """SAO label per spec: lay='Situation', cover='Action', crown='Outcome'. Empty slots still carry their label so the user can see which position is yet to draw.""" from apps.epic.models import personal_sig_cards, TarotCard from apps.gameboard.models import MySeaDraw sig_pile = personal_sig_cards(self.user) self.user.significator = sig_pile[0] self.user.save() card = TarotCard.objects.first() MySeaDraw.objects.create( user=self.user, spread="situation-action-outcome", hand=[ {"position": "lay", "card_id": card.id, "reversed": False, "polarity": "gravity"}, ], significator_id=self.user.significator_id, ) response = self.client.get("/gameboard/") parsed = lxml.html.fromstring(response.content) labels = parsed.cssselect( "#id_applet_my_sea .my-sea-slot-wrap .my-sea-slot-label" ) # Labels in DOM order (== DRAW_ORDER): Situation, Action, Outcome self.assertEqual( [l.text_content().strip() for l in labels], ["Situation", "Action", "Outcome"], ) def test_my_sea_applet_waite_smith_labels_post_fix(self): """Regression pin for the 2026-05-22 POSITION_LABELS swap fix: WS `leave` slot (LEFT) is "Behind", `lay` slot (BOTTOM) is "Beneath" — was inverted prior to the fix. Voronoi mapping depends on this being right.""" from apps.epic.models import personal_sig_cards, TarotCard from apps.gameboard.models import MySeaDraw sig_pile = personal_sig_cards(self.user) self.user.significator = sig_pile[0] self.user.save() card = TarotCard.objects.first() # Single-card WS draw — populates `cover` (DRAW_ORDER pos 1). MySeaDraw.objects.create( user=self.user, spread="waite-smith", hand=[ {"position": "cover", "card_id": card.id, "reversed": False, "polarity": "gravity"}, ], significator_id=self.user.significator_id, ) response = self.client.get("/gameboard/") parsed = lxml.html.fromstring(response.content) wraps = parsed.cssselect("#id_applet_my_sea .my-sea-slot-wrap") # WS DRAW_ORDER = [cover, cross, crown, lay, loom, leave] # Labels post-fix: Cover Cross Crown Beneath Before Behind labels = [ w.cssselect(".my-sea-slot-label")[0].text_content().strip() for w in wraps ] self.assertEqual( labels, ["Cover", "Cross", "Crown", "Beneath", "Before", "Behind"], ) def test_my_sea_applet_renders_polarity_qualifier_per_slot(self): """Each filled slot carries a `.fan-card-qualifier` whose text is the polarity qualifier for the slot's polarity (upright) or the reversal_qualifier (reversed). User-reported 2026-05-23: applet was rendering only the title, no qualifier.""" from apps.epic.models import personal_sig_cards, TarotCard from apps.gameboard.models import MySeaDraw sig_pile = personal_sig_cards(self.user) self.user.significator = sig_pile[0] self.user.save() # Pick a middle court card (Queen of Crowns) — has levity_qualifier # "Elevated", gravity_qualifier "Graven", reversal_qualifier "Vacant". queen_of_crowns = TarotCard.objects.filter( arcana="MIDDLE", suit="CROWNS", number=13, ).first() MySeaDraw.objects.create( user=self.user, spread="situation-action-outcome", hand=[ {"position": "lay", "card_id": queen_of_crowns.id, "reversed": False, "polarity": "levity"}, ], significator_id=self.user.significator_id, ) response = self.client.get("/gameboard/") parsed = lxml.html.fromstring(response.content) quals = parsed.cssselect( "#id_applet_my_sea .my-sea-slot--filled .fan-card-qualifier" ) self.assertEqual(len(quals), 1) self.assertEqual(quals[0].text_content().strip(), "Elevated") def test_my_sea_applet_major_renders_title_comma_qualifier_below(self): """Major Arcana w. a qualifier (trump 9 — 'Erasing Personal History' + 'Sublimating') renders as 'Title,' / 'Qualifier' per the page's stage card convention (`stage-card.js:141-143`). The qualifier `

` lands AFTER the name `

` in DOM order.""" from apps.epic.models import personal_sig_cards, TarotCard from apps.gameboard.models import MySeaDraw sig_pile = personal_sig_cards(self.user) self.user.significator = sig_pile[0] self.user.save() trump_9 = TarotCard.objects.filter( arcana="MAJOR", number=9, deck_variant__slug="earthman", ).first() self.assertIsNotNone(trump_9, "seed migration 0007 should produce Earthman trump 9 " "('Erasing Personal History') w. levity_qualifier='Sublimating'") MySeaDraw.objects.create( user=self.user, spread="situation-action-outcome", hand=[ {"position": "lay", "card_id": trump_9.id, "reversed": False, "polarity": "levity"}, ], significator_id=self.user.significator_id, ) response = self.client.get("/gameboard/") parsed = lxml.html.fromstring(response.content) face = parsed.cssselect( "#id_applet_my_sea .my-sea-slot--filled .fan-card-face" )[0] # DOM order: name → qualifier (NOT qualifier → name). children = [ el for el in face if el.tag == "p" and any( cls in (el.get("class") or "") for cls in ("fan-card-name", "fan-card-qualifier") ) ] self.assertEqual(len(children), 2) self.assertIn("fan-card-name", children[0].get("class")) self.assertIn("fan-card-qualifier", children[1].get("class")) # Title carries a trailing comma; qualifier is "Sublimating" (levity). self.assertTrue( children[0].text_content().strip().endswith(","), f"expected trailing comma on Major title, got " f"{children[0].text_content()!r}", ) self.assertEqual(children[1].text_content().strip(), "Sublimating") def test_my_sea_applet_renders_reversal_qualifier_for_reversed_slot(self): from apps.epic.models import personal_sig_cards, TarotCard from apps.gameboard.models import MySeaDraw sig_pile = personal_sig_cards(self.user) self.user.significator = sig_pile[0] self.user.save() queen_of_crowns = TarotCard.objects.filter( arcana="MIDDLE", suit="CROWNS", number=13, ).first() MySeaDraw.objects.create( user=self.user, spread="situation-action-outcome", hand=[ {"position": "lay", "card_id": queen_of_crowns.id, "reversed": True, "polarity": "gravity"}, ], significator_id=self.user.significator_id, ) response = self.client.get("/gameboard/") parsed = lxml.html.fromstring(response.content) quals = parsed.cssselect( "#id_applet_my_sea .my-sea-slot--filled .fan-card-qualifier" ) self.assertEqual(quals[0].text_content().strip(), "Vacant") def test_gameboard_shows_game_kit(self): [_] = self.parsed.cssselect("#id_game_kit") def test_gameboard_shows_game_gear(self): [_] = self.parsed.cssselect(".gear-btn") def test_my_games_has_no_game_items_for_new_user(self): game_items = self.parsed.cssselect("#id_applet_my_games .game-item") self.assertEqual(len(game_items), 0) def test_my_games_row_shows_latest_event_prose_and_ts(self): from apps.drama.models import GameEvent, record room = Room.objects.create(name="StampedRoom", owner=self.user) record( room, GameEvent.SLOT_FILLED, actor=self.user, slot_number=1, token_type="coin", token_display="Coin-on-a-String", renewal_days=7, ) response = self.client.get("/gameboard/") body = response.content.decode() self.assertIn("StampedRoom", body) # Row carries a ts cell from the recorded event self.assertRegex( body, r'#id_applet_my_games|class="[^"]*row-ts'.replace("#", ""), ) # A .row-body cell carries some event prose self.assertRegex(body, r']+class="[^"]*row-ts') def test_my_games_row_body_includes_actor_display_name(self): """Mirror of My Scrolls — the My Games row body must include the event actor's display_name so ` deposits a Coin-on-a-String` reads as a complete sentence. Scoped to the `.row-body` span (vs. a loose substring) so the assertion can't pass on actor renders outside the row (no Most Recent Scroll on gameboard, but the navbar etc. could shadow a substring match).""" from apps.drama.models import GameEvent, record actor = User.objects.create(email="stuart@test.io", username="stuart") room = Room.objects.create(name="GameRoom", owner=self.user) record( room, GameEvent.SLOT_FILLED, actor=actor, slot_number=2, token_type="coin", token_display="Coin-on-a-String", renewal_days=7, ) response = self.client.get("/gameboard/") body = response.content.decode() self.assertRegex( body, r'[^<]*stuart[^<]*deposits a Coin-on-a-String', ) def test_game_kit_has_coin_on_a_string(self): [_] = self.parsed.cssselect("#id_game_kit #id_kit_coin_on_a_string") def test_game_kit_has_free_token(self): [_] = self.parsed.cssselect("#id_game_kit #id_kit_free_token") def test_game_kit_shows_deck_variant_cards(self): decks = self.parsed.cssselect("#id_game_kit .deck-variant") self.assertGreater(len(decks), 0) # Earthman deck (seeded by migration) should have its own card [_] = self.parsed.cssselect("#id_game_kit #id_kit_earthman_deck") def test_game_kit_has_dice_set_placeholder(self): [_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set") def test_deck_token_renders_card_stack_svg_not_fa_id_badge(self): """Sprint A.4 — `.token.deck-variant` icon is the inline SVG card-stack (`.deck-stack-icon`), not the old `` placeholder. Stack contains 3 rect children w. `.deck-stack-icon__card` classes the CSS keys off for the rest-stack + hover fan-out.""" deck = self.parsed.cssselect("#id_game_kit #id_kit_earthman_deck")[0] # New SVG icon present [svg] = deck.cssselect("svg.deck-stack-icon") cards = svg.cssselect(".deck-stack-icon__card") self.assertEqual( len(cards), 3, "Card-stack icon must render 3 rect cards (top + 2 fan-out)", ) # Old FA icon removed self.assertEqual( len(deck.cssselect("i.fa-id-badge")), 0, "fa-regular fa-id-badge must be gone from deck-variant token", ) def test_polarized_deck_tooltip_has_x2_decoration(self): """Earthman is the only is_polarized=True deck today (per A.0 migration). Its tooltip's card-count line should carry a `(×2)` suffix in --terUser per [[project-card-deck-icon]]'s `is_polarized` tooltip-decoration rule.""" deck = self.parsed.cssselect("#id_game_kit #id_kit_earthman_deck")[0] tt = deck.cssselect(".tt")[0] [x2] = tt.cssselect(".tt-x2") self.assertIn("×2", x2.text_content()) # ×2 def test_nonpolarized_deck_tooltip_lacks_x2_decoration(self): """Non-polarized decks (Tarot RWS, future Minchiate) don't get the `(×2)` decoration — the suffix signals 'double-polarized = 6 segments = fills 2× as many seats' which only applies to polarized decks.""" from apps.epic.models import DeckVariant # Use the migration-renamed RWS deck (formerly fiorentine-minchiate). rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith") self.user.unlocked_decks.add(rws) response = self.client.get("/gameboard/") import lxml.html parsed = lxml.html.fromstring(response.content) deck = parsed.cssselect("#id_game_kit #id_kit_tarot_deck")[0] tt = deck.cssselect(".tt")[0] self.assertEqual( len(tt.cssselect(".tt-x2")), 0, "Non-polarized RWS deck must not show (×2) in tooltip", ) def test_image_equipped_deck_icon_uses_back_image_pattern(self): """Sprint A.4 follow-up — image-equipped decks (Minchiate today, future Earthman) render the SVG card-stack icon w. an inline referencing the deck's -back.png + inline style `fill: url(#deck-back-)` on each rect so the actual card-back renders instead of the placeholder `--priUser` solid fill.""" from apps.epic.models import DeckVariant minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890") self.user.unlocked_decks.add(minchiate) response = self.client.get("/gameboard/") import lxml.html parsed = lxml.html.fromstring(response.content) deck = parsed.cssselect("#id_game_kit #id_kit_minchiate_deck")[0] html = lxml.html.tostring(deck, encoding="unicode") self.assertIn("deck-back-minchiate", html, "Image-equipped deck's SVG must define a back-image ") self.assertIn("minchiate-fiorentine-1860-1890-back.png", html, "Pattern must reference the deck's back-image asset URL") self.assertGreaterEqual( html.count("fill: url(#deck-back-minchiate)"), 3, "Each of the 3 card rects must override its fill via inline style", ) def test_nonimage_deck_icon_has_no_back_pattern(self): """Earthman (has_card_images=False until its art lands) renders the placeholder fill — NO defs, no inline-style fill override, rects fall through to the SCSS default `--priUser` solid fill.""" deck = self.parsed.cssselect("#id_game_kit #id_kit_earthman_deck")[0] import lxml.html html = lxml.html.tostring(deck, encoding="unicode") self.assertNotIn("deck-back-earthman", html, "Non-image deck must not define a back-image ") self.assertNotIn("fill: url(#deck-back-", html, "Non-image deck rects must use the SCSS default fill") class GameboardDeckInUseTest(TestCase): """Sprint 2: game kit applet renders in-use state for a deck assigned to an active seat.""" def setUp(self): self.user = User.objects.create(email="gamer@test.io") self.client.force_login(self.user) Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"}) self.earthman = DeckVariant.objects.get(slug="earthman") self.room = Room.objects.create(name="Wildfire", owner=self.user) self.seat = TableSeat.objects.create( room=self.room, gamer=self.user, slot_number=1, deck_variant=self.earthman, ) response = self.client.get("/gameboard/") self.parsed = lxml.html.fromstring(response.content) def test_in_use_deck_don_is_disabled(self): [don] = self.parsed.cssselect("#id_kit_earthman_deck .btn-equip") self.assertIn("btn-disabled", don.get("class", "")) def test_in_use_deck_doff_is_absent(self): active_doff = self.parsed.cssselect( "#id_kit_earthman_deck .btn-unequip:not(.btn-disabled)" ) self.assertEqual(len(active_doff), 0) def test_in_use_deck_carries_room_name_for_mini_portal(self): [el] = self.parsed.cssselect("#id_kit_earthman_deck") self.assertEqual("Wildfire", el.get("data-in-use-room-name")) def test_non_in_use_deck_has_normal_don(self): fiorentine = DeckVariant.objects.get(slug="tarot-rider-waite-smith") self.user.unlocked_decks.add(fiorentine) response = self.client.get("/gameboard/") parsed = lxml.html.fromstring(response.content) [don] = parsed.cssselect("#id_kit_tarot_deck .btn-equip") self.assertNotIn("btn-disabled", don.get("class", "")) class ToggleGameAppletsViewTest(TestCase): def setUp(self): self.user = User.objects.create(email="gamer@test.io") self.client.force_login(self.user) self.new_game, _ = Applet.objects.get_or_create( slug="new-game", defaults={"name": "New Game", "context": "gameboard"} ) self.my_games, _ = Applet.objects.get_or_create( slug="my-games", defaults={"name": "My Games", "context": "gameboard"} ) self.url = reverse("toggle_game_applets") def test_unauthenticated_user_is_redirected(self): self.client.logout() response = self.client.post(self.url) self.assertRedirects( response, f"/?next={self.url}", fetch_redirect_response=False ) def test_unchecked_applet_gets_user_applet_with_visible_false(self): self.client.post(self.url, {"applets": ["new-game"]}) ua = UserApplet.objects.get(user=self.user, applet=self.my_games) self.assertFalse(ua.visible) def test_redirects_on_normal_post(self): response = self.client.post(self.url, {"applets": ["new-game", "my-games"]}) self.assertRedirects( response, reverse("gameboard"), fetch_redirect_response=False ) def test_returns_200_on_htmx_post(self): response = self.client.post( self.url, {"applets": ["new-game", "my-games"]}, HTTP_HX_REQUEST="true", ) self.assertEqual(response.status_code, 200) def test_does_not_affect_dash_applets(self): dash_applet, _ = Applet.objects.get_or_create( slug="username", defaults={"name": "Username", "context": "dashboard"} ) self.client.post(self.url, {"applets": ["new-game", "my-games"]}) self.assertFalse(UserApplet.objects.filter(user=self.user, applet=dash_applet).exists()) class EquipDeckViewTest(TestCase): def setUp(self): self.user = User.objects.create(email="gamer@test.io") self.client.force_login(self.user) self.deck = DeckVariant.objects.first() def test_get_returns_405(self): response = self.client.get(reverse("equip_deck", kwargs={"deck_id": self.deck.pk})) self.assertEqual(response.status_code, 405) def test_post_equips_deck(self): response = self.client.post(reverse("equip_deck", kwargs={"deck_id": self.deck.pk})) self.assertEqual(response.status_code, 204) self.user.refresh_from_db() self.assertEqual(self.user.equipped_deck, self.deck) class UnequipDeckViewTest(TestCase): def setUp(self): self.user = User.objects.create(email="gamer@test.io") self.deck = DeckVariant.objects.first() self.user.equipped_deck = self.deck self.user.save(update_fields=["equipped_deck"]) self.client.force_login(self.user) def test_get_returns_405(self): response = self.client.get(reverse("unequip_deck", kwargs={"deck_id": self.deck.pk})) self.assertEqual(response.status_code, 405) def test_post_clears_equipped_deck_when_matches(self): response = self.client.post(reverse("unequip_deck", kwargs={"deck_id": self.deck.pk})) self.assertEqual(response.status_code, 204) self.user.refresh_from_db() self.assertIsNone(self.user.equipped_deck) def test_post_ignores_non_matching_deck(self): other_deck = DeckVariant.objects.exclude(pk=self.deck.pk).first() if other_deck is None: self.skipTest("Only one deck variant in DB") self.client.post(reverse("unequip_deck", kwargs={"deck_id": other_deck.pk})) self.user.refresh_from_db() self.assertEqual(self.user.equipped_deck, self.deck) class UnequipTrinketViewTest(TestCase): def setUp(self): self.user = User.objects.create(email="gamer@test.io") self.client.force_login(self.user) self.token = Token.objects.filter(user=self.user, token_type=Token.COIN).first() def test_get_returns_405(self): from apps.lyric.models import Token token = Token.objects.filter(user=self.user).first() if token is None: self.skipTest("No token for user") response = self.client.get(reverse("unequip_trinket", kwargs={"token_id": token.pk})) self.assertEqual(response.status_code, 405) def test_post_clears_equipped_trinket_when_matching(self): self.user.equipped_trinket = self.token self.user.save(update_fields=["equipped_trinket"]) response = self.client.post( reverse("unequip_trinket", kwargs={"token_id": self.token.pk}) ) self.assertEqual(response.status_code, 204) self.user.refresh_from_db() self.assertIsNone(self.user.equipped_trinket) def test_post_ignores_non_matching_trinket(self): """POSTing a token that's not the currently-equipped one is a 204 no-op — equipped_trinket is unchanged. Covers the implicit `else` of the `if request.user.equipped_trinket_id == token.pk` branch.""" other_token = Token.objects.create(user=self.user, token_type=Token.TITHE) self.user.equipped_trinket = self.token # COIN is equipped self.user.save(update_fields=["equipped_trinket"]) response = self.client.post( reverse("unequip_trinket", kwargs={"token_id": other_token.pk}) ) self.assertEqual(response.status_code, 204) self.user.refresh_from_db() self.assertEqual(self.user.equipped_trinket, self.token) class GameKitViewTest(TestCase): def setUp(self): self.user = User.objects.create(email="gamer@test.io") self.client.force_login(self.user) Applet.objects.get_or_create(slug="gk-trinkets", defaults={"name": "Trinkets", "context": "game-kit"}) Applet.objects.get_or_create(slug="gk-tokens", defaults={"name": "Tokens", "context": "game-kit"}) Applet.objects.get_or_create(slug="gk-decks", defaults={"name": "Card Decks", "context": "game-kit"}) Applet.objects.get_or_create(slug="gk-dice", defaults={"name": "Dice Sets", "context": "game-kit"}) Applet.objects.get_or_create(slug="pronouns", defaults={"name": "Pronouns", "context": "game-kit"}) response = self.client.get("/gameboard/game-kit/") self.parsed = lxml.html.fromstring(response.content) def test_game_kit_requires_login(self): self.client.logout() response = self.client.get("/gameboard/game-kit/") self.assertRedirects(response, "/?next=/gameboard/game-kit/", fetch_redirect_response=False) def test_game_kit_shows_gear_btn(self): [_] = self.parsed.cssselect(".gear-btn") def test_game_kit_shows_applet_menu(self): [_] = self.parsed.cssselect("#id_game_kit_menu") def test_game_kit_applet_menu_has_trinkets_checkbox(self): [inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-trinkets']") self.assertEqual(inp.get("type"), "checkbox") def test_game_kit_applet_menu_has_tokens_checkbox(self): [inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-tokens']") self.assertEqual(inp.get("type"), "checkbox") def test_game_kit_applet_menu_has_decks_checkbox(self): [inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-decks']") self.assertEqual(inp.get("type"), "checkbox") def test_game_kit_applet_menu_has_dice_checkbox(self): [inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-dice']") self.assertEqual(inp.get("type"), "checkbox") def test_game_kit_sections_container_present(self): [_] = self.parsed.cssselect("#id_gk_sections_container") def test_all_sections_visible_by_default(self): sections = self.parsed.cssselect("#id_gk_sections_container section") # Trinkets, Tokens, Card Decks, Dice Sets, Pronouns self.assertEqual(len(sections), 5) def test_pronouns_section_renders_five_cards(self): [section] = self.parsed.cssselect("#id_gk_pronouns") cards = section.cssselect(".gk-pronoun-card") self.assertEqual(len(cards), 5) slugs = [c.get("data-pronoun") for c in cards] self.assertEqual( slugs, ["pluralism", "bawlmorese", "misogyny", "misandry", "misanthropy"], ) def test_pronouns_section_marks_current_choice_active(self): # Default user pronouns = "pluralism" — that card should carry .active. [active] = self.parsed.cssselect("#id_gk_pronouns .gk-pronoun-card.active") self.assertEqual(active.get("data-pronoun"), "pluralism") def test_game_kit_applet_menu_has_pronouns_checkbox(self): [inp] = self.parsed.cssselect("#id_game_kit_menu input[value='pronouns']") self.assertEqual(inp.get("type"), "checkbox") def test_fan_stage_block_renders_rank_suit_chip_per_face(self): """Sprint A.7.5 — `#id_fan_stage_block` (the carousel modal's stat block) gains the same `.stat-face-header` w. rank+suit chip + the `.stat-face-title` + `.stat-face-arcana` slots that the my_sign / sea_stage stat blocks have. Previously only the keyword list was present (text-mode decks carried text on the card face); for image- mode the stat block is the sole home for textual metadata.""" for face_cls in ("stat-face--upright", "stat-face--reversed"): face = self.parsed.cssselect(f"#id_fan_stage_block .{face_cls}") self.assertEqual(len(face), 1, f"expected one {face_cls}") [header] = face[0].cssselect(".stat-face-header") [_chip] = header.cssselect(".stat-face-chip") [_rank] = header.cssselect(".stat-chip-rank") [_icon] = header.cssselect("i.stat-chip-icon") [_label] = header.cssselect(".stat-face-label") [_title] = face[0].cssselect(".stat-face-title") [_arcana] = face[0].cssselect(".stat-face-arcana") class ToggleGameKitSectionsViewTest(TestCase): def setUp(self): self.user = User.objects.create(email="gamer@test.io") self.client.force_login(self.user) self.trinkets, _ = Applet.objects.get_or_create( slug="gk-trinkets", defaults={"name": "Trinkets", "context": "game-kit"} ) self.tokens, _ = Applet.objects.get_or_create( slug="gk-tokens", defaults={"name": "Tokens", "context": "game-kit"} ) self.decks, _ = Applet.objects.get_or_create( slug="gk-decks", defaults={"name": "Card Decks", "context": "game-kit"} ) self.dice, _ = Applet.objects.get_or_create( slug="gk-dice", defaults={"name": "Dice Sets", "context": "game-kit"} ) self.url = reverse("toggle_game_kit_sections") def test_unauthenticated_user_is_redirected(self): self.client.logout() response = self.client.post(self.url) self.assertRedirects(response, f"/?next={self.url}", fetch_redirect_response=False) def test_unchecked_section_gets_user_applet_with_visible_false(self): self.client.post(self.url, {"applets": ["gk-trinkets"]}) ua = UserApplet.objects.get(user=self.user, applet=self.tokens) self.assertFalse(ua.visible) def test_redirects_on_normal_post(self): response = self.client.post(self.url, {"applets": ["gk-trinkets", "gk-tokens"]}) self.assertRedirects(response, reverse("game_kit"), fetch_redirect_response=False) def test_returns_200_on_htmx_post(self): response = self.client.post( self.url, {"applets": ["gk-trinkets", "gk-tokens"]}, HTTP_HX_REQUEST="true", ) self.assertEqual(response.status_code, 200) def test_does_not_affect_gameboard_applets(self): gb_applet, _ = Applet.objects.get_or_create( slug="new-game", defaults={"name": "New Game", "context": "gameboard"} ) self.client.post(self.url, {"applets": ["gk-trinkets"]}) self.assertFalse(UserApplet.objects.filter(user=self.user, applet=gb_applet).exists()) def test_hidden_section_absent_from_htmx_response(self): response = self.client.post( self.url, {"applets": ["gk-trinkets"]}, HTTP_HX_REQUEST="true", ) parsed = lxml.html.fromstring(response.content) sections = parsed.cssselect("section") self.assertEqual(len(sections), 1) class EquipTrinketViewTest(TestCase): def setUp(self): self.user = User.objects.create(email="gamer@test.io") self.client.force_login(self.user) self.token = Token.objects.filter(user=self.user, token_type=Token.COIN).first() def test_get_returns_trinket_button_partial(self): response = self.client.get( reverse("equip_trinket", kwargs={"token_id": self.token.pk}) ) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, "apps/gameboard/_partials/_equip_trinket_btn.html") def test_post_equips_trinket_and_returns_204(self): response = self.client.post( reverse("equip_trinket", kwargs={"token_id": self.token.pk}) ) self.assertEqual(response.status_code, 204) self.user.refresh_from_db() self.assertEqual(self.user.equipped_trinket, self.token) def test_post_requires_token_owner(self): outsider = User.objects.create(email="outsider@test.io") self.client.force_login(outsider) response = self.client.post( reverse("equip_trinket", kwargs={"token_id": self.token.pk}) ) # get_object_or_404 — the token belongs to self.user, not outsider self.assertEqual(response.status_code, 404) class TarotFanViewTest(TestCase): def setUp(self): from apps.epic.models import DeckVariant self.earthman = DeckVariant.objects.get(slug="earthman") self.fiorentine = DeckVariant.objects.get(slug="tarot-rider-waite-smith") self.user = User.objects.create(email="fan@test.io") self.client.force_login(self.user) def test_returns_fan_partial_for_unlocked_deck(self): response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.earthman.pk})) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, "apps/gameboard/_partials/_tarot_fan.html") def test_returns_403_for_locked_deck(self): response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.fiorentine.pk})) self.assertEqual(response.status_code, 403) def test_text_mode_deck_keeps_text_scaffold(self): """Sprint A.7.5 — Earthman (has_card_images=False) carousel cards keep the existing `.fan-card-corner` + `.fan-card-face` text scaffold and lack the `.fan-card--image` marker. Pins the text-mode branch as the before-state so the image-mode branch below isn't a regression risk.""" from apps.epic.models import TarotCard # Cap at 5 cards to keep the test focused — the deck has 106 cards. TarotCard.objects.filter(deck_variant=self.earthman).exclude( pk__in=TarotCard.objects.filter(deck_variant=self.earthman)[:5] ).delete() response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.earthman.pk})) parsed = lxml.html.fromstring(response.content) cards = parsed.cssselect(".fan-card") self.assertGreater(len(cards), 0) for card in cards: self.assertNotIn("fan-card--image", card.get("class", "")) self.assertEqual(len(card.cssselect("img.sig-stage-card-img")), 0) self.assertGreater(len(card.cssselect(".fan-card-corner")), 0) self.assertGreater(len(card.cssselect(".fan-card-face")), 0) def test_image_mode_deck_renders_img_per_card_and_drops_text_scaffold(self): """Sprint A.7.5 — Minchiate (has_card_images=True + non-polarized) cards carry `.fan-card--image` + an `` per card + a `` per card (since non-polarized; FLIP flips to back). Text scaffold (corners + face) absent server-side.""" from apps.epic.models import DeckVariant minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890") self.user.unlocked_decks.add(minchiate) response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": minchiate.pk})) self.assertEqual(response.status_code, 200) parsed = lxml.html.fromstring(response.content) cards = parsed.cssselect(".fan-card") # Spot-check the first card; deck has 97 cards. self.assertGreater(len(cards), 0) first = cards[0] self.assertIn("fan-card--image", first.get("class", "")) self.assertEqual(first.get("data-arcana-key"), "MAJOR") # Minchiate trump #0 = Il Matto [img] = first.cssselect("img.sig-stage-card-img") self.assertIn("minchiate-fiorentine-1860-1890", img.get("src", "")) [back_img] = first.cssselect("img.sig-stage-card-back-img") self.assertIn( "minchiate-fiorentine-1860-1890-back.png", back_img.get("src", "") ) # Text scaffold absent across the WHOLE response — none of the cards # in image-mode should render corners/face. self.assertEqual( len(parsed.cssselect(".fan-card-corner")), 0, "image-mode cards must not render the text scaffold", ) self.assertEqual(len(parsed.cssselect(".fan-card-face")), 0) def test_image_mode_polarized_deck_omits_back_img(self): """Polarized image-equipped deck (none today, but the gate is defensive): FLIP retains its polarity-cycle meaning and no back-img renders. Earthman flipped to has_card_images=True simulates the future state where Earthman art lands.""" self.earthman.has_card_images = True self.earthman.save(update_fields=["has_card_images"]) response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.earthman.pk})) parsed = lxml.html.fromstring(response.content) cards = parsed.cssselect(".fan-card") self.assertGreater(len(cards), 0) for card in cards: self.assertIn("fan-card--image", card.get("class", "")) self.assertEqual( len(card.cssselect("img.sig-stage-card-back-img")), 0, "Polarized deck must not render the back-image element", ) class MySeaViewTest(TestCase): """Sprint 3 of the My Sea roadmap — standalone page is a shell only. Sigs / sea-select / gatekeeper phase content lands in later sprints.""" def setUp(self): self.user = User.objects.create(email="sea@test.io") self.client.force_login(self.user) def test_my_sea_requires_login(self): self.client.logout() response = self.client.get(reverse("my_sea")) self.assertRedirects( response, "/?next=/gameboard/my-sea/", fetch_redirect_response=False ) def test_my_sea_renders_200(self): response = self.client.get(reverse("my_sea")) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, "apps/gameboard/my_sea.html") def test_my_sea_uses_gameboard_page_class(self): # `page_class` drives the body class for the landscape layout aperture # — My Sea inherits the gameboard's aperture (same nav/footer rails). response = self.client.get(reverse("my_sea")) self.assertIn("page-gameboard", response.content.decode()) self.assertIn("page-my-sea", response.content.decode()) def test_sea_stage_stat_block_renders_rank_suit_chip_per_face(self): """Sprint A.7.5 — `_sea_stage.html` modal scaffold (included from my_sea-picker-phase + the gameroom sea overlay) carries the new `.stat-face-header` wrapper w. the rank+suit chip inline w. the EMANATION/REVERSAL label. Both upright + reversed faces have their own chip; stage-card.js populateStatExtras fills both identically on each card focus. Rendered standalone via render_to_string since the partial's parent views are phase-gated.""" from django.template.loader import render_to_string html = render_to_string("apps/gameboard/_partials/_sea_stage.html") parsed = lxml.html.fromstring(html) for face_cls in ("stat-face--upright", "stat-face--reversed"): face = parsed.cssselect(f".sea-stat-block .{face_cls}") self.assertEqual(len(face), 1, f"expected one {face_cls}") [header] = face[0].cssselect(".stat-face-header") [chip] = header.cssselect(".stat-face-chip") [_rank] = chip.cssselect(".stat-chip-rank") [_icon] = chip.cssselect("i.stat-chip-icon") [_label] = header.cssselect(".stat-face-label") class MySeaDrawSeaLandingViewTest(TestCase): """Sprint 5 iter 1 — view context for the DRAW SEA landing UX. Pins `no_equipped_deck` + `show_backup_intro_banner` context keys + the presence of the new landing template elements when user passes the Sprint 4b sign-gate.""" def setUp(self): from apps.epic.models import personal_sig_cards self.user = User.objects.create(email="draw@test.io") self.client.force_login(self.user) # Assign a sig so the view's landing branch (not the gate) renders. self.user.significator = personal_sig_cards(self.user)[0] self.user.save(update_fields=["significator"]) def test_context_no_equipped_deck_false_when_user_has_deck(self): # post_save auto-equips Earthman; `no_equipped_deck` should be False. response = self.client.get(reverse("my_sea")) self.assertFalse(response.context["no_equipped_deck"]) def test_context_no_equipped_deck_true_when_user_cleared_deck(self): self.user.equipped_deck = None self.user.save(update_fields=["equipped_deck"]) response = self.client.get(reverse("my_sea")) self.assertTrue(response.context["no_equipped_deck"]) def test_context_show_backup_intro_banner_when_no_deck_and_has_sig(self): # Brief banner fires when user has a sig AND no deck — they're on the # landing UX (gate passed) but headed for the backup-deck draw path. self.user.equipped_deck = None self.user.save(update_fields=["equipped_deck"]) response = self.client.get(reverse("my_sea")) self.assertTrue(response.context["show_backup_intro_banner"]) def test_context_show_backup_intro_banner_false_when_deck_equipped(self): response = self.client.get(reverse("my_sea")) self.assertFalse(response.context["show_backup_intro_banner"]) def test_landing_renders_free_draw_btn_when_sig_set(self): # Element ID `id_draw_sea_btn` describes intent (draw entry point); # visible label is "FREE DRAW" for the daily-free quota draw. response = self.client.get(reverse("my_sea")) self.assertContains(response, 'id="id_draw_sea_btn"') self.assertContains(response, "FREE") self.assertContains(response, "DRAW") def test_landing_renders_six_chair_seats_with_C_suffix(self): response = self.client.get(reverse("my_sea")) html = response.content.decode() for n in range(1, 7): with self.subTest(slot=n): self.assertIn(f'data-slot="{n}"', html) self.assertIn(f"{n}C", html) def test_landing_renders_position_status_ban_icon_on_each_seat(self): # Each chair seat starts empty (red `.fa-ban` status icon). The # FREE DRAW click handler swaps seat 1C's icon to .fa-circle-check # client-side; this IT only pins the initial render state. Class # substrings ("position-status-icon", "fa-ban") ALSO appear in the # inline JS handler (classList.remove arg, querySelector arg) — so # counts are asserted on the full class-attribute string only. response = self.client.get(reverse("my_sea")) html = response.content.decode() self.assertEqual(html.count('class="position-status-icon fa-solid fa-ban"'), 6) self.assertEqual(html.count('class="seat-position-label"'), 6) def test_landing_not_rendered_when_user_has_no_sig(self): # Sprint 4b gate still wins precedence — FREE DRAW must not render # when significator is None. self.user.significator = None self.user.save(update_fields=["significator"]) response = self.client.get(reverse("my_sea")) self.assertNotContains(response, 'id="id_draw_sea_btn"') class MySeaPickerPhaseTemplateTest(TestCase): """Sprint 5 iter 2 — picker-phase template render contract: the three-card cross (sig in core + cover/leave/loom drop zones) is server-rendered (hidden until JS swaps data-phase after FREE DRAW). Crown / lay / cross from the gameroom's 6-position Celtic Cross are deliberately forsaken in the solo flow.""" def setUp(self): from apps.epic.models import personal_sig_cards self.user = User.objects.create(email="picker@test.io") self.client.force_login(self.user) self.target = personal_sig_cards(self.user)[0] self.user.significator = self.target self.user.save(update_fields=["significator"]) def test_picker_renders_significator_in_core_cell(self): response = self.client.get(reverse("my_sea")) html = response.content.decode() # Sig card carries the user's significator id so iter 3's draw # flow can target it for SPIN / FLIP / FYI without re-fetching. self.assertIn('sea-pos-core', html) self.assertIn('sea-sig-card', html) self.assertIn(f'data-card-id="{self.target.id}"', html) def test_picker_renders_cover_leave_loom_positions(self): response = self.client.get(reverse("my_sea")) self.assertContains(response, "sea-pos-cover") self.assertContains(response, "sea-pos-leave") self.assertContains(response, "sea-pos-loom") def test_sea_sig_card_renders_image_when_deck_has_card_images(self): """Sprint A.5 — central sig card on /gameboard/my-sea/ carries the `.sig-stage-card--image` marker class + an child pointing at the deck's image asset when the user's equipped deck is image-equipped (Minchiate today). Mirrors A.3's my_sign.html image-mode treatment so the central sig + the Sea Stage modal render with consistent visual identity.""" from apps.epic.models import DeckVariant, TarotCard minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890") # Override the auto-equipped Earthman w. Minchiate + pick Il Matto as # the user's sig (it's MAJOR rank 0 → permitted by personal_sig_cards # IF user has the super-nomad Note unlock; superuser auto-gets it). self.user.is_superuser = True self.user.save() # Re-run the post_save Note grants for the now-superuser by manually # granting (signal only fires on initial create). from apps.drama.models import Note Note.grant_if_new(self.user, "super-nomad") Note.grant_if_new(self.user, "super-schizo") self.user.unlocked_decks.add(minchiate) self.user.equipped_deck = minchiate il_matto = TarotCard.objects.get(deck_variant=minchiate, slug="il-matto") self.user.significator = il_matto self.user.save(update_fields=["equipped_deck", "significator"]) import lxml.html response = self.client.get(reverse("my_sea")) parsed = lxml.html.fromstring(response.content) [sig_card] = parsed.cssselect(".sea-sig-card") self.assertIn( "sig-stage-card--image", sig_card.get("class", ""), "Sig card must carry --image marker class for Minchiate-equipped user", ) [img] = sig_card.cssselect("img.sig-stage-card-img") self.assertIn( "minchiate-fiorentine-1860-1890-trumps-00-il-matto.png", img.get("src", ""), "Image src must point at the v2-convention Il Matto asset", ) def test_sea_sig_card_renders_text_when_deck_has_no_images(self): """Earthman (has_card_images=False) keeps the existing corner-rank + suit-icon text render — the image branch only applies to image-decks.""" # Default setUp leaves the user on auto-equipped Earthman. import lxml.html response = self.client.get(reverse("my_sea")) parsed = lxml.html.fromstring(response.content) [sig_card] = parsed.cssselect(".sea-sig-card") self.assertNotIn("sig-stage-card--image", sig_card.get("class", "")) self.assertEqual( len(sig_card.cssselect("img.sig-stage-card-img")), 0, "Non-image deck must not render the in the sig card", ) self.assertEqual( len(sig_card.cssselect(".fan-corner-rank")), 1, "Non-image deck falls through to corner-rank text render", ) def test_picker_renders_six_card_only_positions_for_spread_switch(self): # Crown / lay / cross sit in the DOM unconditionally so iter 3's # SPREAD dropdown can reveal them via CSS attribute swap (data- # spread-shape="six-card") without re-rendering. Default 3-card # spread hides them via `.my-sea-cross[data-spread-shape= # "three-card"]` rules in _gameboard.scss — FT pins the hidden # state visually. response = self.client.get(reverse("my_sea")) self.assertContains(response, "sea-pos-crown") self.assertContains(response, "sea-pos-lay") self.assertContains(response, "sea-pos-cross") def test_picker_not_rendered_when_user_has_no_sig(self): # 4b gate wins; picker has no business rendering without a sig. self.user.significator = None self.user.save(update_fields=["significator"]) response = self.client.get(reverse("my_sea")) self.assertNotContains(response, "my-sea-picker") class MySeaPolarityMatchesMySignTest(TestCase): """Bug 2026-05-21 (user-reported): a levity sig chosen on /billboard/ my-sign/ rendered as gravity-styled on /gameboard/my-sea/ (priUser bg + secUser text) — and vice versa for gravity sigs (which then collided w. the hardcoded --secUser corner-rank color in `.sea-sig-card`, making the rank + suit-icon invisible against a --secUser bg). Root cause was a polarity inversion in `my_sea.html:10` — `{% if significator_reversed %}gravity{% else %}levity{% endif %}` — opposite of `my_sign.html:22` + its JS `_polarity()`. This test pins the two surfaces to agree on the same `User.significator_reversed` → `data-polarity` mapping, so any future drift gets caught.""" def setUp(self): from apps.epic.models import personal_sig_cards self.user = User.objects.create(email="polarity@test.io") self.client.force_login(self.user) target = personal_sig_cards(self.user)[0] self.user.significator = target self.user.save(update_fields=["significator"]) def test_unreversed_sig_renders_gravity_on_both_surfaces(self): """`significator_reversed=False` (the on-creation default) renders data-polarity="gravity" on BOTH my_sign + my_sea — the convention established by my_sign's Sprint 4a picker + its `_polarity()` JS.""" self.user.significator_reversed = False self.user.save(update_fields=["significator_reversed"]) sea = self.client.get(reverse("my_sea")).content.decode() sign = self.client.get(reverse("billboard:my_sign")).content.decode() self.assertIn('data-polarity="gravity"', sea) self.assertIn('data-polarity="gravity"', sign) def test_reversed_sig_renders_levity_on_both_surfaces(self): """`significator_reversed=True` (FLIP-toggled on my_sign) renders data-polarity="levity" on BOTH surfaces.""" self.user.significator_reversed = True self.user.save(update_fields=["significator_reversed"]) sea = self.client.get(reverse("my_sea")).content.decode() sign = self.client.get(reverse("billboard:my_sign")).content.decode() self.assertIn('data-polarity="levity"', sea) self.assertIn('data-polarity="levity"', sign) class MySeaSpreadFormTemplateTest(TestCase): """Sprint 5 iter 3 — form col + SPREAD dropdown structure + default- spread context + cross's `data-spread-shape` attribute. Iter 3 spec locks `Situation, Action, Outcome` as the default spread (a 3-card variant); the 6 spreads sit under 2 section dividers (3-card / 6- card).""" SPREAD_OPTIONS = [ ("past-present-future", "Past, Present, Future"), ("situation-action-outcome", "Situation, Action, Outcome"), ("mind-body-spirit", "Mind, Body, Spirit"), ("desire-obstacle-solution", "Desire, Obstacle, Solution"), ("waite-smith", "Celtic Cross, Waite-Smith"), ("escape-velocity", "Celtic Cross, Escape Velocity"), ] def setUp(self): from apps.epic.models import personal_sig_cards self.user = User.objects.create(email="spread@test.io") self.client.force_login(self.user) self.target = personal_sig_cards(self.user)[0] self.user.significator = self.target self.user.save(update_fields=["significator"]) def test_context_default_spread_is_situation_action_outcome(self): response = self.client.get(reverse("my_sea")) self.assertEqual( response.context["default_spread"], "situation-action-outcome", ) def test_context_reversals_pct_defaults_to_25(self): response = self.client.get(reverse("my_sea")) self.assertEqual(response.context["reversals_pct"], 25) def test_template_renders_all_six_spread_options(self): response = self.client.get(reverse("my_sea")) html = response.content.decode() for value, label in self.SPREAD_OPTIONS: with self.subTest(spread=value): self.assertIn(f'data-value="{value}"', html) self.assertIn(label, html) def test_template_renders_three_card_and_six_card_section_dividers(self): response = self.client.get(reverse("my_sea")) html = response.content.decode() self.assertEqual(html.count("sea-select-divider"), 2) self.assertIn("3-card spreads", html) self.assertIn("6-card spreads", html) def test_template_marks_situation_action_outcome_aria_selected(self): response = self.client.get(reverse("my_sea")) html = response.content.decode() # The default option carries aria-selected="true"; the others false. self.assertIn( 'data-value="situation-action-outcome" aria-selected="true"', html, ) def test_cross_carries_initial_data_spread_sao(self): # `.my-sea-cross[data-spread]` is the per-spread visibility key; # default-spread context value renders into the attribute. SCSS # rules in _gameboard.scss hide the inactive positions per spread. response = self.client.get(reverse("my_sea")) self.assertContains(response, 'data-spread="situation-action-outcome"') def test_template_renders_sao_position_labels_on_default(self): # Server-renders the SAO position labels into the empty drop-zone # `.sea-pos-label` spans so the page is correct before JS boots. # JS swaps labels on spread change. response = self.client.get(reverse("my_sea")) html = response.content.decode() self.assertIn('data-position="lay">Situation', html) self.assertIn('data-position="cover">Action', html) self.assertIn('data-position="crown">Outcome', html) # Inactive-for-SAO positions render their span but w. empty # textContent (JS fills them on spread switch). self.assertIn('data-position="leave">', html) self.assertIn('data-position="loom">', html) self.assertIn('data-position="cross">', html) def test_form_col_renders_decks_action_btn_del_and_reversal_hint(self): # Iter 4c — LOCK HAND replaced by AUTO DRAW (mid-draw) which JS # transitions to GATE VIEW on completion. ID `id_sea_action_btn` # is the single slot housing both states (label + `data-state` # toggled by JS). User w. no active draw → AUTO DRAW label. response = self.client.get(reverse("my_sea")) html = response.content.decode() self.assertIn("sea-deck-stack--gravity", html) self.assertIn("sea-deck-stack--levity", html) self.assertIn('id="id_sea_action_btn"', html) self.assertIn('data-state="auto-draw"', html) self.assertIn("AUTO", html) self.assertIn('id="id_sea_del"', html) self.assertIn("sea-reversal-hint", html) self.assertIn("25% reversals", html) class MySeaDeckDataViewTest(TestCase): """Sprint 5 iter 4a — view-level deck-data contract. `my_sea` view embeds a shuffled deck (levity + gravity halves, current user's significator excluded, reversal pre-rolled at ~25%) as JSON via the `sea_deck_data` context key + `{{ ...|json_script }}` filter in the template.""" def setUp(self): from apps.epic.models import personal_sig_cards self.user = User.objects.create(email="deck@test.io") self.client.force_login(self.user) self.target = personal_sig_cards(self.user)[0] self.user.significator = self.target self.user.save(update_fields=["significator"]) def test_context_sea_deck_data_has_two_polarity_halves(self): response = self.client.get(reverse("my_sea")) deck = response.context["sea_deck_data"] self.assertIn("levity", deck) self.assertIn("gravity", deck) self.assertIsInstance(deck["levity"], list) self.assertIsInstance(deck["gravity"], list) def test_deck_data_excludes_user_significator(self): response = self.client.get(reverse("my_sea")) deck = response.context["sea_deck_data"] all_ids = ( {c["id"] for c in deck["levity"]} | {c["id"] for c in deck["gravity"]} ) self.assertNotIn(self.target.id, all_ids) def test_deck_data_halves_are_disjoint(self): response = self.client.get(reverse("my_sea")) deck = response.context["sea_deck_data"] levity_ids = {c["id"] for c in deck["levity"]} gravity_ids = {c["id"] for c in deck["gravity"]} self.assertEqual(levity_ids & gravity_ids, set()) def test_deck_data_cards_carry_corner_rank_suit_icon_and_reversed(self): # Card dict shape mirrors the gameroom `sea_deck` endpoint so # iter 4b's persistence/render path can reuse the JSON contract. response = self.client.get(reverse("my_sea")) deck = response.context["sea_deck_data"] any_card = (deck["levity"] + deck["gravity"])[0] for key in ("id", "corner_rank", "suit_icon", "reversed"): with self.subTest(key=key): self.assertIn(key, any_card) self.assertIsInstance(any_card["reversed"], bool) def test_template_embeds_deck_as_json_script(self): # Embed mechanism: `{{ sea_deck_data|json_script:"id_my_sea_deck" }}` # gives a `