Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1154 lines
52 KiB
Python
1154 lines
52 KiB
Python
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'<time[^>]+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 `<actor> 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'<span class="row-body">[^<]*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</span>', html)
|
|
self.assertIn('data-position="cover">Action</span>', html)
|
|
self.assertIn('data-position="crown">Outcome</span>', html)
|
|
# Inactive-for-SAO positions render their span but w. empty
|
|
# textContent (JS fills them on spread switch).
|
|
self.assertIn('data-position="leave"></span>', html)
|
|
self.assertIn('data-position="loom"></span>', html)
|
|
self.assertIn('data-position="cross"></span>', 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 `<script type="application/json" id="id_my_sea_deck">`.
|
|
response = self.client.get(reverse("my_sea"))
|
|
self.assertContains(
|
|
response,
|
|
'<script id="id_my_sea_deck" type="application/json">',
|
|
)
|
|
|
|
def test_deck_data_empty_when_user_has_no_equipped_deck(self):
|
|
# Backup-deck branch: per [[sprint-my-sign-picker-may18h]] follow-
|
|
# up, no-deck users still proceed via Earthman. So deck_data falls
|
|
# back to Earthman, NOT empty. (Earthman seed is migration-loaded
|
|
# in this TestCase context.)
|
|
self.user.equipped_deck = None
|
|
self.user.save(update_fields=["equipped_deck"])
|
|
response = self.client.get(reverse("my_sea"))
|
|
deck = response.context["sea_deck_data"]
|
|
self.assertGreater(len(deck["levity"]) + len(deck["gravity"]), 0)
|
|
|
|
|
|
# ── Sprint 5 iter 4b — server persistence: MySeaDraw + lock + delete ──────────
|
|
|
|
|
|
class MySeaDrawModelTest(TestCase):
|
|
"""Sprint 5 iter 4b — `MySeaDraw` model + `active_draw_for` helper."""
|
|
|
|
def setUp(self):
|
|
from apps.epic.models import personal_sig_cards
|
|
self.user = User.objects.create(email="model@test.io")
|
|
self.target = personal_sig_cards(self.user)[0]
|
|
self.user.significator = self.target
|
|
self.user.save(update_fields=["significator"])
|
|
|
|
def _build_hand(self):
|
|
from apps.epic.models import TarotCard
|
|
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
|
|
return [
|
|
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
|
|
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
|
|
{"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
|
|
]
|
|
|
|
def test_create_round_trips_hand_and_sig_snapshot(self):
|
|
from apps.gameboard.models import MySeaDraw
|
|
hand = self._build_hand()
|
|
draw = MySeaDraw.objects.create(
|
|
user=self.user, spread="situation-action-outcome",
|
|
hand=hand, significator_id=self.target.id, significator_reversed=False,
|
|
)
|
|
draw.refresh_from_db()
|
|
self.assertEqual(draw.hand, hand)
|
|
self.assertEqual(draw.significator_id, self.target.id)
|
|
|
|
def test_next_free_draw_at_is_created_at_plus_24h(self):
|
|
from datetime import timedelta
|
|
from apps.gameboard.models import MySeaDraw, FREE_DRAW_COOLDOWN_HOURS
|
|
draw = MySeaDraw.objects.create(
|
|
user=self.user, spread="situation-action-outcome",
|
|
hand=self._build_hand(), significator_id=self.target.id,
|
|
)
|
|
delta = draw.next_free_draw_at - draw.created_at
|
|
self.assertEqual(delta, timedelta(hours=FREE_DRAW_COOLDOWN_HOURS))
|
|
|
|
def test_is_within_quota_window_true_when_fresh(self):
|
|
from apps.gameboard.models import MySeaDraw
|
|
draw = MySeaDraw.objects.create(
|
|
user=self.user, spread="situation-action-outcome",
|
|
hand=self._build_hand(), significator_id=self.target.id,
|
|
)
|
|
self.assertTrue(draw.is_within_quota_window)
|
|
|
|
def test_is_within_quota_window_false_when_older_than_24h(self):
|
|
from datetime import timedelta
|
|
from django.utils import timezone
|
|
from apps.gameboard.models import MySeaDraw
|
|
draw = MySeaDraw.objects.create(
|
|
user=self.user, spread="situation-action-outcome",
|
|
hand=self._build_hand(), significator_id=self.target.id,
|
|
created_at=timezone.now() - timedelta(hours=25),
|
|
)
|
|
self.assertFalse(draw.is_within_quota_window)
|
|
|
|
def test_active_draw_for_returns_recent_draw(self):
|
|
from apps.gameboard.models import MySeaDraw, active_draw_for
|
|
draw = MySeaDraw.objects.create(
|
|
user=self.user, spread="situation-action-outcome",
|
|
hand=self._build_hand(), significator_id=self.target.id,
|
|
)
|
|
self.assertEqual(active_draw_for(self.user), draw)
|
|
|
|
def test_active_draw_for_returns_none_when_no_draws(self):
|
|
from apps.gameboard.models import active_draw_for
|
|
self.assertIsNone(active_draw_for(self.user))
|
|
|
|
def test_active_draw_for_returns_none_when_only_stale_draws(self):
|
|
from datetime import timedelta
|
|
from django.utils import timezone
|
|
from apps.gameboard.models import MySeaDraw, active_draw_for
|
|
MySeaDraw.objects.create(
|
|
user=self.user, spread="situation-action-outcome",
|
|
hand=self._build_hand(), significator_id=self.target.id,
|
|
created_at=timezone.now() - timedelta(hours=25),
|
|
)
|
|
self.assertIsNone(active_draw_for(self.user))
|
|
|
|
def test_active_draw_for_scopes_to_user(self):
|
|
from apps.gameboard.models import MySeaDraw, active_draw_for
|
|
other = User.objects.create(email="other@test.io")
|
|
MySeaDraw.objects.create(
|
|
user=other, spread="situation-action-outcome",
|
|
hand=self._build_hand(), significator_id=self.target.id,
|
|
)
|
|
self.assertIsNone(active_draw_for(self.user))
|
|
|
|
|
|
class MySeaLockHandViewTest(TestCase):
|
|
"""Sprint 5 iter 4b — POST `/gameboard/my-sea/lock` persists a hand."""
|
|
|
|
def setUp(self):
|
|
from apps.epic.models import personal_sig_cards
|
|
self.user = User.objects.create(email="lock@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"])
|
|
self.url = reverse("my_sea_lock")
|
|
|
|
def _build_payload(self, spread="situation-action-outcome", hand=None):
|
|
from apps.epic.models import TarotCard
|
|
if hand is None:
|
|
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
|
|
hand = [
|
|
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
|
|
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
|
|
{"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
|
|
]
|
|
return {"spread": spread, "hand": hand}
|
|
|
|
def test_lock_requires_login(self):
|
|
import json
|
|
self.client.logout()
|
|
response = self.client.post(
|
|
self.url, data=json.dumps(self._build_payload()),
|
|
content_type="application/json",
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
def test_lock_get_returns_405(self):
|
|
response = self.client.get(self.url)
|
|
self.assertEqual(response.status_code, 405)
|
|
|
|
def test_lock_post_creates_my_sea_draw_for_user(self):
|
|
import json
|
|
from apps.gameboard.models import MySeaDraw
|
|
response = self.client.post(
|
|
self.url, data=json.dumps(self._build_payload()),
|
|
content_type="application/json",
|
|
)
|
|
self.assertEqual(response.status_code, 200)
|
|
draw = MySeaDraw.objects.get(user=self.user)
|
|
self.assertEqual(draw.spread, "situation-action-outcome")
|
|
self.assertEqual(len(draw.hand), 3)
|
|
self.assertEqual(draw.significator_id, self.target.id)
|
|
|
|
def test_lock_post_response_includes_next_free_draw_iso_timestamp(self):
|
|
import json
|
|
from datetime import datetime
|
|
response = self.client.post(
|
|
self.url, data=json.dumps(self._build_payload()),
|
|
content_type="application/json",
|
|
)
|
|
body = response.json()
|
|
self.assertIn("next_free_draw_at", body)
|
|
# Round-trip parse — the server is expected to send an ISO 8601 string.
|
|
parsed = datetime.fromisoformat(body["next_free_draw_at"])
|
|
self.assertIsNotNone(parsed)
|
|
|
|
def test_lock_post_within_quota_window_returns_409(self):
|
|
# Second lock within 24h: the existing draw already occupies the
|
|
# quota; the server rejects rather than overwriting.
|
|
import json
|
|
from apps.gameboard.models import MySeaDraw
|
|
self.client.post(
|
|
self.url, data=json.dumps(self._build_payload()),
|
|
content_type="application/json",
|
|
)
|
|
response = self.client.post(
|
|
self.url, data=json.dumps(self._build_payload()),
|
|
content_type="application/json",
|
|
)
|
|
self.assertEqual(response.status_code, 409)
|
|
self.assertEqual(MySeaDraw.objects.filter(user=self.user).count(), 1)
|
|
|
|
def test_lock_post_empty_hand_returns_400(self):
|
|
import json
|
|
response = self.client.post(
|
|
self.url, data=json.dumps({"spread": "situation-action-outcome", "hand": []}),
|
|
content_type="application/json",
|
|
)
|
|
self.assertEqual(response.status_code, 400)
|
|
|
|
def test_lock_post_missing_spread_returns_400(self):
|
|
import json
|
|
payload = self._build_payload()
|
|
payload.pop("spread")
|
|
response = self.client.post(
|
|
self.url, data=json.dumps(payload),
|
|
content_type="application/json",
|
|
)
|
|
self.assertEqual(response.status_code, 400)
|
|
|
|
def test_lock_post_snapshots_user_significator(self):
|
|
import json
|
|
from apps.gameboard.models import MySeaDraw
|
|
self.client.post(
|
|
self.url, data=json.dumps(self._build_payload()),
|
|
content_type="application/json",
|
|
)
|
|
draw = MySeaDraw.objects.get(user=self.user)
|
|
# Sig snapshot persists even after user clears their sig.
|
|
self.user.significator = None
|
|
self.user.save(update_fields=["significator"])
|
|
draw.refresh_from_db()
|
|
self.assertEqual(draw.significator_id, self.target.id)
|
|
|
|
|
|
class MySeaDeleteDrawViewTest(TestCase):
|
|
"""Sprint 5 iter 4b — POST `/gameboard/my-sea/delete` clears the draw."""
|
|
|
|
def setUp(self):
|
|
from apps.epic.models import personal_sig_cards, TarotCard
|
|
from apps.gameboard.models import MySeaDraw
|
|
self.user = User.objects.create(email="del@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"])
|
|
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
|
|
self.draw = MySeaDraw.objects.create(
|
|
user=self.user, spread="situation-action-outcome",
|
|
significator_id=self.target.id,
|
|
hand=[
|
|
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
|
|
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
|
|
{"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
|
|
],
|
|
)
|
|
self.url = reverse("my_sea_delete")
|
|
|
|
def test_delete_requires_login(self):
|
|
self.client.logout()
|
|
response = self.client.post(self.url)
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
def test_delete_get_returns_405(self):
|
|
response = self.client.get(self.url)
|
|
self.assertEqual(response.status_code, 405)
|
|
|
|
def test_delete_post_clears_active_draw(self):
|
|
from apps.gameboard.models import MySeaDraw
|
|
response = self.client.post(self.url)
|
|
self.assertIn(response.status_code, (200, 204, 302))
|
|
self.assertFalse(MySeaDraw.objects.filter(user=self.user).exists())
|
|
|
|
def test_delete_post_scoped_to_user_does_not_touch_others(self):
|
|
from apps.gameboard.models import MySeaDraw
|
|
other = User.objects.create(email="other-del@test.io")
|
|
other_draw = MySeaDraw.objects.create(
|
|
user=other, spread="situation-action-outcome",
|
|
hand=self.draw.hand, significator_id=self.target.id,
|
|
)
|
|
self.client.post(self.url)
|
|
self.assertTrue(MySeaDraw.objects.filter(pk=other_draw.pk).exists())
|
|
|
|
def test_delete_post_idempotent_when_no_active_draw(self):
|
|
# User deletes twice in a row — second call is a no-op, not a 500.
|
|
self.client.post(self.url)
|
|
response = self.client.post(self.url)
|
|
self.assertIn(response.status_code, (200, 204, 302))
|
|
|
|
|
|
class MySeaViewWithSavedDrawTest(TestCase):
|
|
"""Sprint 5 iter 4b — `my_sea` view branches when an active draw exists.
|
|
|
|
Active draw bypasses the sign-gate (sig snapshot on the draw is used
|
|
even if `user.significator` is None), bypasses the landing phase (the
|
|
saved hand IS what the user came to see), and adds a Brief banner +
|
|
next-free-draw timestamp to the context."""
|
|
|
|
def setUp(self):
|
|
from apps.epic.models import personal_sig_cards, TarotCard
|
|
from apps.gameboard.models import MySeaDraw
|
|
self.user = User.objects.create(email="saved@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"])
|
|
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
|
|
self.draw = MySeaDraw.objects.create(
|
|
user=self.user, spread="situation-action-outcome",
|
|
significator_id=self.target.id,
|
|
hand=[
|
|
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
|
|
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
|
|
{"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
|
|
],
|
|
)
|
|
|
|
def test_context_carries_active_draw(self):
|
|
response = self.client.get(reverse("my_sea"))
|
|
self.assertEqual(response.context["active_draw"], self.draw)
|
|
|
|
def test_context_default_spread_is_saved_spread(self):
|
|
response = self.client.get(reverse("my_sea"))
|
|
self.assertEqual(response.context["default_spread"], self.draw.spread)
|
|
|
|
def test_context_carries_next_free_draw_iso(self):
|
|
from datetime import datetime
|
|
response = self.client.get(reverse("my_sea"))
|
|
ts = response.context["next_free_draw_at"]
|
|
# Either a datetime instance or an ISO string the template renders.
|
|
if isinstance(ts, str):
|
|
self.assertIsNotNone(datetime.fromisoformat(ts))
|
|
else:
|
|
self.assertIsNotNone(ts.isoformat())
|
|
|
|
def test_saved_draw_bypasses_sign_gate_even_when_user_sig_cleared(self):
|
|
# User-spec'd: a cleared sig doesn't invalidate the saved draw.
|
|
# The view must still render the picker phase (NOT the sign-gate)
|
|
# by falling back to the draw's snapshotted sig.
|
|
self.user.significator = None
|
|
self.user.save(update_fields=["significator"])
|
|
response = self.client.get(reverse("my_sea"))
|
|
html = response.content.decode()
|
|
self.assertNotIn("my-sea-sign-gate", html)
|
|
self.assertIn('data-phase="picker"', html)
|
|
|
|
def test_view_renders_brief_banner_when_active_draw_exists(self):
|
|
response = self.client.get(reverse("my_sea"))
|
|
self.assertContains(response, "my-sea-brief")
|
|
self.assertContains(response, "my-sea-brief__timestamp")
|
|
self.assertContains(response, "my-sea-brief__nvm")
|
|
|
|
def test_brief_banner_hidden_without_active_draw(self):
|
|
# Markup is rendered unconditionally so JS can un-hide it on LOCK
|
|
# HAND POST success without a page reload. When no active_draw,
|
|
# the wrapping div carries `[hidden]` so the banner is invisible.
|
|
from apps.gameboard.models import MySeaDraw
|
|
MySeaDraw.objects.all().delete()
|
|
response = self.client.get(reverse("my_sea"))
|
|
self.assertContains(response, '<div class="my-sea-brief" hidden>')
|
|
|
|
def test_view_renders_del_guard_portal_when_active_draw_exists(self):
|
|
response = self.client.get(reverse("my_sea"))
|
|
self.assertContains(response, 'id="id_my_sea_del_portal"')
|
|
self.assertContains(response, "my-sea-del-portal__confirm")
|
|
self.assertContains(response, "my-sea-del-portal__nvm")
|
|
|
|
def test_saved_hand_renders_as_filled_slots_in_picker(self):
|
|
# Each saved position's slot is server-rendered as `--filled` w.
|
|
# the snapshotted card id + polarity. JS-init then layers any
|
|
# post-load behaviours (label re-rendering, stage-card lookups).
|
|
response = self.client.get(reverse("my_sea"))
|
|
html = response.content.decode()
|
|
for entry in self.draw.hand:
|
|
self.assertIn(f'data-card-id="{entry["card_id"]}"', html)
|
|
self.assertIn(f"sea-card-slot--{entry['polarity']}", html)
|
|
|
|
def test_landing_phase_suppressed_when_active_draw_exists(self):
|
|
response = self.client.get(reverse("my_sea"))
|
|
self.assertNotContains(response, 'id="id_draw_sea_btn"')
|
|
self.assertNotContains(response, 'data-phase="landing"')
|