import lxml.html from django.test import TestCase from django.urls import reverse 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_lock_hand_del_and_reversal_hint(self): 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_lock_hand"', 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 `