Picker phase form col: SPREAD combobox w. 6 spread options under 2 horizontal section dividers ("3-card spreads" / "6-card spreads"), reversal-% caption, GRAVITY + LEVITY deck swatches, LOCK HAND + DEL btns. Default = Situation, Action, Outcome (a 3-card spread). Selecting a 6-card spread (Celtic Cross Waite-Smith or Escape Velocity) swaps `.my-sea-cross[data-spread-shape]` from `three-card` to `six-card` — revealing the crown / lay / cross cells that the default 3-card variants hide.
Naming correction (user-locked): the spread itself is a "three-card spread" not a "three-card cross" — "cross" stays scoped to the Celtic Cross variants (6-card spreads). CSS class `.my-sea-cross` carries grid-container semantics regardless of which spread shape is active; the spread-vs-cross distinction lives at the spread-name layer only.
- **View** (gameboard/views.py): `my_sea` adds `default_spread = "situation-action-outcome"` + `reversals_pct = 25` context keys.
- **Template** (my_sea.html): renders all 7 cross cells (crown/leave/core+cover+cross/loom/lay) unconditionally + adds `data-spread-shape="three-card"` to `.my-sea-cross`. Form col DRY-reuses gameroom `_sea_overlay.html`'s `.sea-form-col` shape — `.sea-form-main` w. `.sea-field` (SPREAD label + reversal hint + custom combobox) + `.sea-stacks` (GRAVITY + LEVITY swatches) + `.sea-form-actions` (LOCK HAND + DEL). 6 options + 2 dividers in the combobox `<ul>`; dividers are `role="presentation"` so `combobox.js` skips them naturally. Inline IIFE listens for the hidden `<input id="id_sea_spread">`'s `change` event + sets `.my-sea-cross`'s `data-spread-shape` based on whether the value is in `['waite-smith', 'escape-velocity']`. No new combobox.js wiring — the existing module's `change`-bubbling contract feeds straight in.
- **SCSS** (_gameboard.scss):
- `.my-sea-cross[data-spread-shape="three-card"]` — single-row `"leave core loom"` grid + `display: none` on crown/lay/cross.
- `.my-sea-cross[data-spread-shape="six-card"]` — inherits the gameroom `.sea-cross`'s 3×3 grid + reveals all cells.
- `.sea-select-divider` — section header style mirrors `.kit-bag-label`'s small-uppercase-underlined-letter-spaced --quaUser/0.75 treatment but HORIZONTAL (kit-bag uses `writing-mode: vertical-rl`; dropdown menus are flat). `pointer-events: none` belt-and-braces against accidental click/hover.
- `.my-sea-form-col` — width-constrains the form col so the picker's cross + form sit side-by-side.
**Iter-2 contract updated** (cells in DOM, hidden via CSS for 3-card default):
- FT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_hides_six_card_only_positions_by_default` — asserts the 3 cells are in the DOM but `is_displayed() == False` so iter-3's spread switch can reveal them via CSS without re-rendering.
- IT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_renders_six_card_only_positions_for_spread_switch` — assertContains the classes (server now renders them unconditionally).
**Tests**:
- 4 FTs in new `MySeaSpreadFormTest`: combobox renders 6 options + 2 dividers w. correct labels, default is Situation/Action/Outcome (hidden input value + visible current-label span + cross's data-spread-shape), picking Celtic Cross flips data-spread-shape to six-card + reveals crown/lay/cross, form col carries DECKS swatches + LOCK HAND + DEL + reversal-% caption. Combobox `<li>` options are inside `aria-expanded='false'` listbox → use `get_attribute("textContent")` not `.text` (which returns "" for Selenium-hidden elements).
- 7 ITs in new `MySeaSpreadFormTemplateTest`: default_spread + reversals_pct context keys, all 6 options + both labels render, 2 dividers render w. expected text, default option carries aria-selected="true", cross's initial data-spread-shape="three-card", form col DECKS + buttons + reversal hint render.
Tests: 32/32 FT green across test_bill_my_sign + test_game_my_sea; 1048/1048 IT/UT green in 52s.
Card-draw mechanics (clicking a deck swatch deposits a card into the next empty slot; LOCK HAND commits the draw) defer to iter 4 — this iter ships the spread-selection + layout-shape switch UI; the buttons are stubs (LOCK HAND starts disabled, DEL is a placeholder).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
711 lines
32 KiB
Python
711 lines
32 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_three_card_spread_shape(self):
|
|
response = self.client.get(reverse("my_sea"))
|
|
self.assertContains(response, 'data-spread-shape="three-card"')
|
|
|
|
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)
|