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_renders_sign_gate_for_user_without_sig(self): # Sprint 4b — user with no significator sees the Look!-formatted # gate (mirror of the standalone page), not the draw UX. [_gate] = self.parsed.cssselect( "#id_applet_my_sea .my-sea-sign-gate--applet" ) # Draw-state nodes are suppressed while the gate is up. self.assertEqual( len(self.parsed.cssselect("#id_applet_my_sea .my-sea-empty")), 0, ) self.assertEqual( len(self.parsed.cssselect("#id_applet_my_sea .my-sea-card")), 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. 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-card")), 0, ) self.assertEqual( len(parsed.cssselect("#id_applet_my_sea .my-sea-sign-gate--applet")), 0, ) 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_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") 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="fiorentine-minchiate") self.user.unlocked_decks.add(fiorentine) response = self.client.get("/gameboard/") parsed = lxml.html.fromstring(response.content) [don] = parsed.cssselect("#id_kit_fiorentine_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") 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="fiorentine-minchiate") 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) 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()) 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_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 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 `