(1) _tarot_fan.html image-mode branch — server-side `{% if card.deck_variant.has_card_images %}` gate: image-mode renders `<img class="sig-stage-card-img">` + (for non-polarized decks) a sibling `<img class="sig-stage-card-back-img">` for the FLIP-to-back affordance; text-mode keeps the existing `.fan-card-corner --tl/--br` + `.fan-card-face` scaffold unchanged (Earthman + RWS today; will be removed once both decks get artwork — user's plan: scrape RWS art tonight + Earthman public-domain paintings to follow; "shabby cardstock" non-equippable Earthman variant retains text rendering as legacy preservation). New `.fan-card.fan-card--image` marker class added to the shared image-mode comma-list selector (`_card-deck.scss:705-765`) so the carousel cards pick up the contour-stroke + depth-shadow filter chain + `.is-flipped-to-back` toggle for free — single SCSS source of truth across all 5 image-mode surfaces. Also added `data-arcana-key="{{ card.arcana }}"` + `data-image-url="{{ card.image_url|default:'' }}"` data-attrs to every fan-card so `StageCard.fromDataset` + `_setImageMode` flow w. no extra plumbing.
(2) Game Kit carousel JS rewiring (`game-kit.js`): `_populateStage` now also calls `StageCard.populateStatExtras(stageBlock, card)` so the carousel stat block gets title + arcana + chip populated on every card focus (previously the stage block had only the keyword list; the call site simply wasn't wired). SPIN handler gates the 180° card rotation behind `!active.classList.contains('fan-card--image')` — for image-mode cards SPIN now just toggles `.is-reversed` on the stat block to swap EMANATION ↔ REVERSAL content w/o rotating the artwork (user-spec 2026-05-25 PM: "monodecks shouldn't have gravity and levity polarity"; image artwork is symmetric + shouldn't be inverted by a UI cycle). New `_flipToBack` helper mirrors the my_sign.html A.5-polish-2 FLIP-to-back animation (rotateY 0→90→0 over 500ms, `.is-flipped-to-back` toggle at 250ms midpoint, `data-flipping` cleared at 500ms); the existing `_flipActive` dispatches to it via `active.querySelector('.sig-stage-card-back-img')` presence check (the back-img element is only server-rendered for non-polarized image-equipped decks, so its presence is the gate). Polarized text-mode (Earthman) keeps the existing polarity-cycle FLIP. Per-card-change cleanup also clears `.is-flipped-to-back` on every card so a back-flipped card returns to front when it leaves focus (mirrors the SPIN reset semantics).
(3) Top-left rank+suit chip retrofit (4 stat-block surfaces): the A.3 Q3 spec called for a chip but explicitly deferred to "Lower-priority follow-ups" in the project memory; user pulled it in this sprint as part of the carousel rewrite. New `.stat-face-header` flex wrapper holds the chip + EMANATION/REVERSAL label inline (chip is 2 rows tall, label is 1 — flex `align-items: flex-start` keeps them "vaguely inline" per spec). Chip mirrors the existing `.fan-card-corner` pattern: vertically stacked rank + suit-icon, no chrome (initial draft had a bordered pill — corrected per user clarification 2026-05-25 PM "vertically stacked, --secUser, in the top-left corner"). All 4 stat-block templates (my_sign.html / _applet-my-sign.html / _sea_stage.html / game_kit.html's `#id_fan_stage_block`) get the new header wrapper around their existing `.stat-face-label`. Applet renders the chip server-side from `card.corner_rank` + `card.suit_icon`; the other 3 surfaces leave the chip elements empty + populated by `StageCard.populateStatExtras` on each card focus (the helper now also walks `.stat-chip-rank` + `.stat-chip-icon` w. the same find-all + textContent / className pattern it already uses for title + arcana). Chip color is --secUser by default; polarity-aware overrides for surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block) flip the chip to --priUser for visibility — same logical inversion the keyword list rules already use.
(4) Trump fa-hand-dots fallback in `TarotCard.suit_icon` — was reading the per-card `icon` field then returning `''` for any major arcana w/o an explicit override. Earthman's seed migration 0007 set `icon="fa-hand-dots"` on trumps 2+ as the universal trump symbol, but trumps 0/1 + every Minchiate trump fell through to empty + rendered the chip as just a number/numeral w. no icon below. Promoted the fallback into the model property (per-card override still wins via the `self.icon` branch), so every trump everywhere — chip, text-mode corner, future surfaces — gets a hand-with-dots glyph for free. Updated `TarotCardSuitIconTest.test_major_without_icon_returns_empty` → `test_major_without_icon_defaults_to_hand_dots`.
(5) EMANATION/REVERSAL → --secUser (user-spec 2026-05-25 PM, mid-sprint): label color was --terUser (gold) across all 4 surfaces; flipped to --secUser everywhere so the label recedes against the title (gold/--quaUser per arcana stays the focal text). Default in the shared `stat-block-shared` mixin + applet bespoke `.stat-face-label` rule both updated. Per-polarity overrides: levity (bg --priUser) → label --secUser everywhere; gravity overrides preserved at --quiUser on the 3 surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block — --secUser label would be invisible against --secUser bg, so --quiUser stays for contrast); applet gravity bg is --priUser (just full alpha vs. the default 0.8 — different from the other surfaces) so its gravity override removed entirely, label uses the shared --secUser default in both polarities. User-confirmed visually 2026-05-25 PM: applet EMANATION now in --secUser (`rgb(162, 170, 173)`) matching the chip color — chip + label read as a coordinated header pair rather than competing w. the title.
Tests: 1314/1314 IT+UT total green (76s; +8 new in this sprint — 4 chip-presence ITs across the 4 stat-block surfaces, 3 _tarot_fan image-mode-branch ITs covering image-equipped + text-mode + polarized-image-equipped permutations, 1 UT-rename for the trump fa-hand-dots default). Surfaces NOT covered by ITs: SCSS layout (visual-only — verified live via Claudezilla on /gameboard/game-kit/ Minchiate carousel, /billboard/my-sign/ stage card, /billboard/ applet preview); JS-side chip-fill via populateStatExtras (covered transitively by the populateStatExtras existing call sites — no new test for the chip-specific code path since the test surface for stage-card.js is currently Jasmine-only via FanStageSpec.js, deferred). No new FT runs per [[feedback-ft-run-discipline]] — all changes are template / SCSS / JS / model property; IT coverage is comprehensive for the server-rendered surfaces + the visual verify covered the JS-populated surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2763 lines
129 KiB
Python
2763 lines
129 KiB
Python
import lxml.html
|
||
from datetime import timedelta
|
||
|
||
from django.test import TestCase
|
||
from django.urls import reverse
|
||
from django.utils import timezone
|
||
|
||
from apps.applets.models import Applet, UserApplet
|
||
from apps.epic.models import DeckVariant, Room, TableSeat
|
||
from apps.lyric.models import Token, User
|
||
|
||
|
||
class GameboardViewTest(TestCase):
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="gamer@test.io")
|
||
self.client.force_login(self.user)
|
||
Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"})
|
||
Applet.objects.get_or_create(slug="my-games", defaults={"name": "My Games", "context": "gameboard"})
|
||
Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"})
|
||
response = self.client.get("/gameboard/")
|
||
self.parsed = lxml.html.fromstring(response.content)
|
||
|
||
def test_gameboard_requires_login(self):
|
||
self.client.logout()
|
||
response = self.client.get("/gameboard/")
|
||
self.assertRedirects(
|
||
response, "/?next=/gameboard/", fetch_redirect_response=False
|
||
)
|
||
|
||
def test_gameboard_renders(self):
|
||
response = self.client.get("/gameboard/")
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
def test_gameboard_shows_my_games_applet(self):
|
||
[_] = self.parsed.cssselect("#id_applet_my_games")
|
||
|
||
def test_gameboard_shows_new_game_applet(self):
|
||
[_] = self.parsed.cssselect("#id_applet_new_game")
|
||
|
||
def test_gameboard_shows_my_sea_applet(self):
|
||
# Sprint 3 of the My Sea roadmap — applet shell only; sigs/sea/draw
|
||
# flow lands in later sprints. Seeded via migration 0008.
|
||
[_] = self.parsed.cssselect("#id_applet_my_sea")
|
||
|
||
def test_my_sea_applet_fires_sign_gate_brief_for_user_without_sig(self):
|
||
# Sprint 4b (refactored 2026-05-22) — user with no significator
|
||
# gets a Look!-formatted Brief banner (`Brief.showBanner` script
|
||
# fired in the applet template) AND the applet body falls through
|
||
# to the empty-state "No draws yet" (no sig → no draws is the only
|
||
# possible state). The Brief itself renders client-side via JS so
|
||
# we assert the script content + the FYI url, not the DOM banner.
|
||
html = self.parsed.text_content() if False else \
|
||
lxml.html.tostring(self.parsed, encoding="unicode")
|
||
self.assertIn("Look!", html)
|
||
self.assertIn("pick your sign before drawing the Sea", html)
|
||
self.assertIn("Brief.showBanner", html)
|
||
# FYI url baked into the Brief script's `post_url`
|
||
self.assertIn("/billboard/my-sign/", html)
|
||
# Old inline gate markup is gone
|
||
self.assertEqual(
|
||
len(self.parsed.cssselect(".my-sea-sign-gate")), 0,
|
||
)
|
||
# Card cells suppressed (no active draw possible without sig)
|
||
self.assertEqual(
|
||
len(self.parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled")), 0,
|
||
)
|
||
|
||
def test_my_sea_applet_renders_empty_state_for_user_with_sig_no_draws(self):
|
||
# Sig set + no saved draws → the scroll container hosts a single
|
||
# placeholder line ("No draws yet."), no card cells, no gate Brief.
|
||
from apps.epic.models import personal_sig_cards
|
||
sig_pile = personal_sig_cards(self.user)
|
||
self.user.significator = sig_pile[0]
|
||
self.user.save()
|
||
response = self.client.get("/gameboard/")
|
||
parsed = lxml.html.fromstring(response.content)
|
||
[empty] = parsed.cssselect("#id_applet_my_sea .my-sea-empty")
|
||
self.assertIn("No draws yet", empty.text_content())
|
||
self.assertEqual(
|
||
len(parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled")), 0,
|
||
)
|
||
# No sign-gate Brief script fires when the user already has a sig
|
||
html = lxml.html.tostring(parsed, encoding="unicode")
|
||
self.assertNotIn("pick your sign before drawing the Sea", html)
|
||
|
||
def test_my_sea_applet_header_links_to_my_sea_page(self):
|
||
[link] = self.parsed.cssselect("#id_applet_my_sea h2 a")
|
||
self.assertEqual(link.get("href"), reverse("my_sea"))
|
||
|
||
def test_my_sea_applet_renders_drawn_cards_in_draw_order(self):
|
||
"""User w. a partial SAO draw — applet renders 3 slots in DRAW_
|
||
ORDER (lay, cover, crown → labelled Beneath/Cover/Crown? no —
|
||
SAO labels are Situation/Action/Outcome). Drawn slot 1 (`lay`)
|
||
carries the card; the un-drawn `cover` + `crown` slots are
|
||
empty placeholders w. their per-spread labels."""
|
||
from apps.epic.models import personal_sig_cards, TarotCard
|
||
from apps.gameboard.models import MySeaDraw
|
||
sig_pile = personal_sig_cards(self.user)
|
||
self.user.significator = sig_pile[0]
|
||
self.user.save()
|
||
card = TarotCard.objects.first()
|
||
MySeaDraw.objects.create(
|
||
user=self.user,
|
||
spread="situation-action-outcome",
|
||
hand=[
|
||
{"position": "lay", "card_id": card.id,
|
||
"reversed": False, "polarity": "gravity"},
|
||
],
|
||
significator_id=self.user.significator_id,
|
||
)
|
||
response = self.client.get("/gameboard/")
|
||
parsed = lxml.html.fromstring(response.content)
|
||
# All 3 SAO positions render in DRAW_ORDER (lay, cover, crown).
|
||
wraps = parsed.cssselect("#id_applet_my_sea .my-sea-slot-wrap")
|
||
self.assertEqual(len(wraps), 3,
|
||
"SAO has 3 positions — applet should render 3 slot wraps")
|
||
# Position 1 (`lay`) is filled w. the drawn card.
|
||
filled = parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled")
|
||
self.assertEqual(len(filled), 1)
|
||
self.assertEqual(
|
||
filled[0].get("data-position"), "lay",
|
||
"First drawn card should land in the `lay` slug slot",
|
||
)
|
||
self.assertEqual(
|
||
filled[0].get("data-card-id"), str(card.id),
|
||
)
|
||
# Positions 2 + 3 (cover, crown) are empty placeholders.
|
||
empties = parsed.cssselect("#id_applet_my_sea .my-sea-slot--empty")
|
||
self.assertEqual(len(empties), 2)
|
||
empty_positions = {e.get("data-position") for e in empties}
|
||
self.assertEqual(empty_positions, {"cover", "crown"})
|
||
|
||
def test_my_sea_applet_slot_renders_image_when_deck_has_card_images(self):
|
||
"""Sprint A.7 — when the drawn card belongs to an image-equipped deck
|
||
(Minchiate today), the .my-sea-slot--filled carries `.my-sea-slot--image`
|
||
+ renders an <img.sig-stage-card-img> child instead of the text scaffold.
|
||
Shares the contour-stroke + depth shadow SCSS w. my_sign + my-sea
|
||
central sig + my-sign-applet via the comma-list selector in
|
||
`_card-deck.scss`."""
|
||
from apps.epic.models import personal_sig_cards, TarotCard, DeckVariant
|
||
from apps.gameboard.models import MySeaDraw
|
||
sig_pile = personal_sig_cards(self.user)
|
||
self.user.significator = sig_pile[0]
|
||
self.user.save()
|
||
minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
|
||
# Use a Minchiate card for the slot draw (doesn't need to be the sig
|
||
# — the sig is from Earthman; the drawn card is a separate concept).
|
||
card = TarotCard.objects.filter(
|
||
deck_variant=minchiate, slug="il-matto",
|
||
).first()
|
||
MySeaDraw.objects.create(
|
||
user=self.user,
|
||
spread="situation-action-outcome",
|
||
hand=[{"position": "lay", "card_id": card.id,
|
||
"reversed": False, "polarity": "gravity"}],
|
||
significator_id=self.user.significator_id,
|
||
)
|
||
response = self.client.get("/gameboard/")
|
||
parsed = lxml.html.fromstring(response.content)
|
||
[filled] = parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled")
|
||
self.assertIn("my-sea-slot--image", filled.get("class", ""))
|
||
self.assertEqual(filled.get("data-arcana-key"), "MAJOR")
|
||
[img] = filled.cssselect("img.sig-stage-card-img")
|
||
self.assertIn(
|
||
"minchiate-fiorentine-1860-1890-trumps-00-il-matto.png",
|
||
img.get("src", ""),
|
||
)
|
||
# Text scaffold absent in image mode.
|
||
self.assertEqual(
|
||
len(filled.cssselect(".fan-card-corner")), 0,
|
||
"Image-mode slot must not render the text scaffold",
|
||
)
|
||
|
||
def test_my_sea_applet_renders_slots_even_when_user_significator_cleared(self):
|
||
"""Regression (user bug 2026-05-25 PM): deleting User.significator
|
||
(via my-sign DEL) must NOT blank the My Sea applet on the gameboard
|
||
when the user has saved MySeaDraw slots. The MySeaDraw row carries
|
||
its own significator_id snapshot at first-draw-time so the saved
|
||
draw is durable even after the user clears their sig. Applet display
|
||
is now decoupled from `request.user.significator_id` — the sig-gate
|
||
Brief banner only fires for users with NO draws AND no sig."""
|
||
from apps.epic.models import personal_sig_cards, TarotCard
|
||
from apps.gameboard.models import MySeaDraw
|
||
sig_pile = personal_sig_cards(self.user)
|
||
snapshot_sig = sig_pile[0]
|
||
card = TarotCard.objects.first()
|
||
MySeaDraw.objects.create(
|
||
user=self.user,
|
||
spread="situation-action-outcome",
|
||
hand=[
|
||
{"position": "lay", "card_id": card.id,
|
||
"reversed": False, "polarity": "gravity"},
|
||
],
|
||
significator_id=snapshot_sig.id,
|
||
)
|
||
# User clears their sig AFTER the draw was saved (the bug repro).
|
||
self.user.significator = None
|
||
self.user.significator_reversed = False
|
||
self.user.save(update_fields=["significator", "significator_reversed"])
|
||
self.assertIsNone(self.user.significator_id)
|
||
|
||
response = self.client.get("/gameboard/")
|
||
parsed = lxml.html.fromstring(response.content)
|
||
# Slots render despite no current sig — the MySeaDraw row owns the
|
||
# display, not the user's live sig state.
|
||
filled = parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled")
|
||
self.assertEqual(
|
||
len(filled), 1,
|
||
"Filled slot must persist even when User.significator_id is None",
|
||
)
|
||
self.assertEqual(filled[0].get("data-card-id"), str(card.id))
|
||
# And the "No draws yet" empty state must NOT render.
|
||
empties = parsed.cssselect("#id_applet_my_sea .my-sea-empty")
|
||
self.assertEqual(
|
||
len(empties), 0,
|
||
"Applet must not render 'No draws yet' when slots exist",
|
||
)
|
||
|
||
def test_my_sea_applet_labels_match_locked_spread(self):
|
||
"""SAO label per spec: lay='Situation', cover='Action',
|
||
crown='Outcome'. Empty slots still carry their label so the
|
||
user can see which position is yet to draw."""
|
||
from apps.epic.models import personal_sig_cards, TarotCard
|
||
from apps.gameboard.models import MySeaDraw
|
||
sig_pile = personal_sig_cards(self.user)
|
||
self.user.significator = sig_pile[0]
|
||
self.user.save()
|
||
card = TarotCard.objects.first()
|
||
MySeaDraw.objects.create(
|
||
user=self.user,
|
||
spread="situation-action-outcome",
|
||
hand=[
|
||
{"position": "lay", "card_id": card.id,
|
||
"reversed": False, "polarity": "gravity"},
|
||
],
|
||
significator_id=self.user.significator_id,
|
||
)
|
||
response = self.client.get("/gameboard/")
|
||
parsed = lxml.html.fromstring(response.content)
|
||
labels = parsed.cssselect(
|
||
"#id_applet_my_sea .my-sea-slot-wrap .my-sea-slot-label"
|
||
)
|
||
# Labels in DOM order (== DRAW_ORDER): Situation, Action, Outcome
|
||
self.assertEqual(
|
||
[l.text_content().strip() for l in labels],
|
||
["Situation", "Action", "Outcome"],
|
||
)
|
||
|
||
def test_my_sea_applet_waite_smith_labels_post_fix(self):
|
||
"""Regression pin for the 2026-05-22 POSITION_LABELS swap fix:
|
||
WS `leave` slot (LEFT) is "Behind", `lay` slot (BOTTOM) is
|
||
"Beneath" — was inverted prior to the fix. Voronoi mapping
|
||
depends on this being right."""
|
||
from apps.epic.models import personal_sig_cards, TarotCard
|
||
from apps.gameboard.models import MySeaDraw
|
||
sig_pile = personal_sig_cards(self.user)
|
||
self.user.significator = sig_pile[0]
|
||
self.user.save()
|
||
card = TarotCard.objects.first()
|
||
# Single-card WS draw — populates `cover` (DRAW_ORDER pos 1).
|
||
MySeaDraw.objects.create(
|
||
user=self.user,
|
||
spread="waite-smith",
|
||
hand=[
|
||
{"position": "cover", "card_id": card.id,
|
||
"reversed": False, "polarity": "gravity"},
|
||
],
|
||
significator_id=self.user.significator_id,
|
||
)
|
||
response = self.client.get("/gameboard/")
|
||
parsed = lxml.html.fromstring(response.content)
|
||
wraps = parsed.cssselect("#id_applet_my_sea .my-sea-slot-wrap")
|
||
# WS DRAW_ORDER = [cover, cross, crown, lay, loom, leave]
|
||
# Labels post-fix: Cover Cross Crown Beneath Before Behind
|
||
labels = [
|
||
w.cssselect(".my-sea-slot-label")[0].text_content().strip()
|
||
for w in wraps
|
||
]
|
||
self.assertEqual(
|
||
labels,
|
||
["Cover", "Cross", "Crown", "Beneath", "Before", "Behind"],
|
||
)
|
||
|
||
def test_my_sea_applet_renders_polarity_qualifier_per_slot(self):
|
||
"""Each filled slot carries a `.fan-card-qualifier` whose text is
|
||
the polarity qualifier for the slot's polarity (upright) or the
|
||
reversal_qualifier (reversed). User-reported 2026-05-23: applet
|
||
was rendering only the title, no qualifier."""
|
||
from apps.epic.models import personal_sig_cards, TarotCard
|
||
from apps.gameboard.models import MySeaDraw
|
||
sig_pile = personal_sig_cards(self.user)
|
||
self.user.significator = sig_pile[0]
|
||
self.user.save()
|
||
# Pick a middle court card (Queen of Crowns) — has levity_qualifier
|
||
# "Elevated", gravity_qualifier "Graven", reversal_qualifier "Vacant".
|
||
queen_of_crowns = TarotCard.objects.filter(
|
||
arcana="MIDDLE", suit="CROWNS", number=13,
|
||
).first()
|
||
MySeaDraw.objects.create(
|
||
user=self.user,
|
||
spread="situation-action-outcome",
|
||
hand=[
|
||
{"position": "lay", "card_id": queen_of_crowns.id,
|
||
"reversed": False, "polarity": "levity"},
|
||
],
|
||
significator_id=self.user.significator_id,
|
||
)
|
||
response = self.client.get("/gameboard/")
|
||
parsed = lxml.html.fromstring(response.content)
|
||
quals = parsed.cssselect(
|
||
"#id_applet_my_sea .my-sea-slot--filled .fan-card-qualifier"
|
||
)
|
||
self.assertEqual(len(quals), 1)
|
||
self.assertEqual(quals[0].text_content().strip(), "Elevated")
|
||
|
||
def test_my_sea_applet_major_renders_title_comma_qualifier_below(self):
|
||
"""Major Arcana w. a qualifier (trump 9 — 'Erasing Personal
|
||
History' + 'Sublimating') renders as 'Title,' / 'Qualifier' per
|
||
the page's stage card convention (`stage-card.js:141-143`). The
|
||
qualifier `<p>` lands AFTER the name `<p>` in DOM order."""
|
||
from apps.epic.models import personal_sig_cards, TarotCard
|
||
from apps.gameboard.models import MySeaDraw
|
||
sig_pile = personal_sig_cards(self.user)
|
||
self.user.significator = sig_pile[0]
|
||
self.user.save()
|
||
trump_9 = TarotCard.objects.filter(
|
||
arcana="MAJOR", number=9, deck_variant__slug="earthman",
|
||
).first()
|
||
self.assertIsNotNone(trump_9,
|
||
"seed migration 0007 should produce Earthman trump 9 "
|
||
"('Erasing Personal History') w. levity_qualifier='Sublimating'")
|
||
MySeaDraw.objects.create(
|
||
user=self.user,
|
||
spread="situation-action-outcome",
|
||
hand=[
|
||
{"position": "lay", "card_id": trump_9.id,
|
||
"reversed": False, "polarity": "levity"},
|
||
],
|
||
significator_id=self.user.significator_id,
|
||
)
|
||
response = self.client.get("/gameboard/")
|
||
parsed = lxml.html.fromstring(response.content)
|
||
face = parsed.cssselect(
|
||
"#id_applet_my_sea .my-sea-slot--filled .fan-card-face"
|
||
)[0]
|
||
# DOM order: name → qualifier (NOT qualifier → name).
|
||
children = [
|
||
el for el in face
|
||
if el.tag == "p"
|
||
and any(
|
||
cls in (el.get("class") or "")
|
||
for cls in ("fan-card-name", "fan-card-qualifier")
|
||
)
|
||
]
|
||
self.assertEqual(len(children), 2)
|
||
self.assertIn("fan-card-name", children[0].get("class"))
|
||
self.assertIn("fan-card-qualifier", children[1].get("class"))
|
||
# Title carries a trailing comma; qualifier is "Sublimating" (levity).
|
||
self.assertTrue(
|
||
children[0].text_content().strip().endswith(","),
|
||
f"expected trailing comma on Major title, got "
|
||
f"{children[0].text_content()!r}",
|
||
)
|
||
self.assertEqual(children[1].text_content().strip(), "Sublimating")
|
||
|
||
def test_my_sea_applet_renders_reversal_qualifier_for_reversed_slot(self):
|
||
from apps.epic.models import personal_sig_cards, TarotCard
|
||
from apps.gameboard.models import MySeaDraw
|
||
sig_pile = personal_sig_cards(self.user)
|
||
self.user.significator = sig_pile[0]
|
||
self.user.save()
|
||
queen_of_crowns = TarotCard.objects.filter(
|
||
arcana="MIDDLE", suit="CROWNS", number=13,
|
||
).first()
|
||
MySeaDraw.objects.create(
|
||
user=self.user,
|
||
spread="situation-action-outcome",
|
||
hand=[
|
||
{"position": "lay", "card_id": queen_of_crowns.id,
|
||
"reversed": True, "polarity": "gravity"},
|
||
],
|
||
significator_id=self.user.significator_id,
|
||
)
|
||
response = self.client.get("/gameboard/")
|
||
parsed = lxml.html.fromstring(response.content)
|
||
quals = parsed.cssselect(
|
||
"#id_applet_my_sea .my-sea-slot--filled .fan-card-qualifier"
|
||
)
|
||
self.assertEqual(quals[0].text_content().strip(), "Vacant")
|
||
|
||
def test_gameboard_shows_game_kit(self):
|
||
[_] = self.parsed.cssselect("#id_game_kit")
|
||
|
||
def test_gameboard_shows_game_gear(self):
|
||
[_] = self.parsed.cssselect(".gear-btn")
|
||
|
||
def test_my_games_has_no_game_items_for_new_user(self):
|
||
game_items = self.parsed.cssselect("#id_applet_my_games .game-item")
|
||
self.assertEqual(len(game_items), 0)
|
||
|
||
def test_my_games_row_shows_latest_event_prose_and_ts(self):
|
||
from apps.drama.models import GameEvent, record
|
||
room = Room.objects.create(name="StampedRoom", owner=self.user)
|
||
record(
|
||
room, GameEvent.SLOT_FILLED, actor=self.user,
|
||
slot_number=1, token_type="coin",
|
||
token_display="Coin-on-a-String", renewal_days=7,
|
||
)
|
||
response = self.client.get("/gameboard/")
|
||
body = response.content.decode()
|
||
self.assertIn("StampedRoom", body)
|
||
# Row carries a ts cell from the recorded event
|
||
self.assertRegex(
|
||
body,
|
||
r'#id_applet_my_games|class="[^"]*row-ts'.replace("#", ""),
|
||
)
|
||
# A .row-body cell carries some event prose
|
||
self.assertRegex(body, r'<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")
|
||
|
||
def test_deck_token_renders_card_stack_svg_not_fa_id_badge(self):
|
||
"""Sprint A.4 — `.token.deck-variant` icon is the inline SVG card-stack
|
||
(`.deck-stack-icon`), not the old `<i class="fa-regular fa-id-badge">`
|
||
placeholder. Stack contains 3 rect children w. `.deck-stack-icon__card`
|
||
classes the CSS keys off for the rest-stack + hover fan-out."""
|
||
deck = self.parsed.cssselect("#id_game_kit #id_kit_earthman_deck")[0]
|
||
# New SVG icon present
|
||
[svg] = deck.cssselect("svg.deck-stack-icon")
|
||
cards = svg.cssselect(".deck-stack-icon__card")
|
||
self.assertEqual(
|
||
len(cards), 3,
|
||
"Card-stack icon must render 3 rect cards (top + 2 fan-out)",
|
||
)
|
||
# Old FA icon removed
|
||
self.assertEqual(
|
||
len(deck.cssselect("i.fa-id-badge")), 0,
|
||
"fa-regular fa-id-badge must be gone from deck-variant token",
|
||
)
|
||
|
||
def test_polarized_deck_tooltip_has_x2_decoration(self):
|
||
"""Earthman is the only is_polarized=True deck today (per A.0 migration).
|
||
Its tooltip's card-count line should carry a `(×2)` suffix in --terUser
|
||
per [[project-card-deck-icon]]'s `is_polarized` tooltip-decoration rule."""
|
||
deck = self.parsed.cssselect("#id_game_kit #id_kit_earthman_deck")[0]
|
||
tt = deck.cssselect(".tt")[0]
|
||
[x2] = tt.cssselect(".tt-x2")
|
||
self.assertIn("×2", x2.text_content()) # ×2
|
||
|
||
def test_nonpolarized_deck_tooltip_lacks_x2_decoration(self):
|
||
"""Non-polarized decks (Tarot RWS, future Minchiate) don't get the
|
||
`(×2)` decoration — the suffix signals 'double-polarized = 6 segments
|
||
= fills 2× as many seats' which only applies to polarized decks."""
|
||
from apps.epic.models import DeckVariant
|
||
# Use the migration-renamed RWS deck (formerly fiorentine-minchiate).
|
||
rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
|
||
self.user.unlocked_decks.add(rws)
|
||
response = self.client.get("/gameboard/")
|
||
import lxml.html
|
||
parsed = lxml.html.fromstring(response.content)
|
||
deck = parsed.cssselect("#id_game_kit #id_kit_tarot_deck")[0]
|
||
tt = deck.cssselect(".tt")[0]
|
||
self.assertEqual(
|
||
len(tt.cssselect(".tt-x2")), 0,
|
||
"Non-polarized RWS deck must not show (×2) in tooltip",
|
||
)
|
||
|
||
def test_image_equipped_deck_icon_uses_back_image_pattern(self):
|
||
"""Sprint A.4 follow-up — image-equipped decks (Minchiate today,
|
||
future Earthman) render the SVG card-stack icon w. an inline <pattern>
|
||
referencing the deck's <deck-slug>-back.png + inline style `fill:
|
||
url(#deck-back-<short_key>)` on each rect so the actual card-back
|
||
renders instead of the placeholder `--priUser` solid fill."""
|
||
from apps.epic.models import DeckVariant
|
||
minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
|
||
self.user.unlocked_decks.add(minchiate)
|
||
response = self.client.get("/gameboard/")
|
||
import lxml.html
|
||
parsed = lxml.html.fromstring(response.content)
|
||
deck = parsed.cssselect("#id_game_kit #id_kit_minchiate_deck")[0]
|
||
html = lxml.html.tostring(deck, encoding="unicode")
|
||
self.assertIn("deck-back-minchiate", html,
|
||
"Image-equipped deck's SVG must define a back-image <pattern>")
|
||
self.assertIn("minchiate-fiorentine-1860-1890-back.png", html,
|
||
"Pattern must reference the deck's back-image asset URL")
|
||
self.assertGreaterEqual(
|
||
html.count("fill: url(#deck-back-minchiate)"), 3,
|
||
"Each of the 3 card rects must override its fill via inline style",
|
||
)
|
||
|
||
def test_nonimage_deck_icon_has_no_back_pattern(self):
|
||
"""Earthman (has_card_images=False until its art lands) renders the
|
||
placeholder fill — NO <pattern> defs, no inline-style fill override,
|
||
rects fall through to the SCSS default `--priUser` solid fill."""
|
||
deck = self.parsed.cssselect("#id_game_kit #id_kit_earthman_deck")[0]
|
||
import lxml.html
|
||
html = lxml.html.tostring(deck, encoding="unicode")
|
||
self.assertNotIn("deck-back-earthman", html,
|
||
"Non-image deck must not define a back-image <pattern>")
|
||
self.assertNotIn("fill: url(#deck-back-", html,
|
||
"Non-image deck rects must use the SCSS default fill")
|
||
|
||
|
||
class GameboardDeckInUseTest(TestCase):
|
||
"""Sprint 2: game kit applet renders in-use state for a deck assigned to an active seat."""
|
||
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="gamer@test.io")
|
||
self.client.force_login(self.user)
|
||
Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"})
|
||
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||
self.room = Room.objects.create(name="Wildfire", owner=self.user)
|
||
self.seat = TableSeat.objects.create(
|
||
room=self.room, gamer=self.user, slot_number=1,
|
||
deck_variant=self.earthman,
|
||
)
|
||
response = self.client.get("/gameboard/")
|
||
self.parsed = lxml.html.fromstring(response.content)
|
||
|
||
def test_in_use_deck_don_is_disabled(self):
|
||
[don] = self.parsed.cssselect("#id_kit_earthman_deck .btn-equip")
|
||
self.assertIn("btn-disabled", don.get("class", ""))
|
||
|
||
def test_in_use_deck_doff_is_absent(self):
|
||
active_doff = self.parsed.cssselect(
|
||
"#id_kit_earthman_deck .btn-unequip:not(.btn-disabled)"
|
||
)
|
||
self.assertEqual(len(active_doff), 0)
|
||
|
||
def test_in_use_deck_carries_room_name_for_mini_portal(self):
|
||
[el] = self.parsed.cssselect("#id_kit_earthman_deck")
|
||
self.assertEqual("Wildfire", el.get("data-in-use-room-name"))
|
||
|
||
def test_non_in_use_deck_has_normal_don(self):
|
||
fiorentine = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
|
||
self.user.unlocked_decks.add(fiorentine)
|
||
response = self.client.get("/gameboard/")
|
||
parsed = lxml.html.fromstring(response.content)
|
||
[don] = parsed.cssselect("#id_kit_tarot_deck .btn-equip")
|
||
self.assertNotIn("btn-disabled", don.get("class", ""))
|
||
|
||
|
||
class ToggleGameAppletsViewTest(TestCase):
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="gamer@test.io")
|
||
self.client.force_login(self.user)
|
||
self.new_game, _ = Applet.objects.get_or_create(
|
||
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
||
)
|
||
self.my_games, _ = Applet.objects.get_or_create(
|
||
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
|
||
)
|
||
self.url = reverse("toggle_game_applets")
|
||
|
||
def test_unauthenticated_user_is_redirected(self):
|
||
self.client.logout()
|
||
response = self.client.post(self.url)
|
||
self.assertRedirects(
|
||
response, f"/?next={self.url}", fetch_redirect_response=False
|
||
)
|
||
|
||
def test_unchecked_applet_gets_user_applet_with_visible_false(self):
|
||
self.client.post(self.url, {"applets": ["new-game"]})
|
||
ua = UserApplet.objects.get(user=self.user, applet=self.my_games)
|
||
self.assertFalse(ua.visible)
|
||
|
||
def test_redirects_on_normal_post(self):
|
||
response = self.client.post(self.url, {"applets": ["new-game", "my-games"]})
|
||
self.assertRedirects(
|
||
response, reverse("gameboard"), fetch_redirect_response=False
|
||
)
|
||
|
||
def test_returns_200_on_htmx_post(self):
|
||
response = self.client.post(
|
||
self.url,
|
||
{"applets": ["new-game", "my-games"]},
|
||
HTTP_HX_REQUEST="true",
|
||
)
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
def test_does_not_affect_dash_applets(self):
|
||
dash_applet, _ = Applet.objects.get_or_create(
|
||
slug="username", defaults={"name": "Username", "context": "dashboard"}
|
||
)
|
||
self.client.post(self.url, {"applets": ["new-game", "my-games"]})
|
||
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=dash_applet).exists())
|
||
|
||
|
||
class EquipDeckViewTest(TestCase):
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="gamer@test.io")
|
||
self.client.force_login(self.user)
|
||
self.deck = DeckVariant.objects.first()
|
||
|
||
def test_get_returns_405(self):
|
||
response = self.client.get(reverse("equip_deck", kwargs={"deck_id": self.deck.pk}))
|
||
self.assertEqual(response.status_code, 405)
|
||
|
||
def test_post_equips_deck(self):
|
||
response = self.client.post(reverse("equip_deck", kwargs={"deck_id": self.deck.pk}))
|
||
self.assertEqual(response.status_code, 204)
|
||
self.user.refresh_from_db()
|
||
self.assertEqual(self.user.equipped_deck, self.deck)
|
||
|
||
|
||
class UnequipDeckViewTest(TestCase):
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="gamer@test.io")
|
||
self.deck = DeckVariant.objects.first()
|
||
self.user.equipped_deck = self.deck
|
||
self.user.save(update_fields=["equipped_deck"])
|
||
self.client.force_login(self.user)
|
||
|
||
def test_get_returns_405(self):
|
||
response = self.client.get(reverse("unequip_deck", kwargs={"deck_id": self.deck.pk}))
|
||
self.assertEqual(response.status_code, 405)
|
||
|
||
def test_post_clears_equipped_deck_when_matches(self):
|
||
response = self.client.post(reverse("unequip_deck", kwargs={"deck_id": self.deck.pk}))
|
||
self.assertEqual(response.status_code, 204)
|
||
self.user.refresh_from_db()
|
||
self.assertIsNone(self.user.equipped_deck)
|
||
|
||
def test_post_ignores_non_matching_deck(self):
|
||
other_deck = DeckVariant.objects.exclude(pk=self.deck.pk).first()
|
||
if other_deck is None:
|
||
self.skipTest("Only one deck variant in DB")
|
||
self.client.post(reverse("unequip_deck", kwargs={"deck_id": other_deck.pk}))
|
||
self.user.refresh_from_db()
|
||
self.assertEqual(self.user.equipped_deck, self.deck)
|
||
|
||
|
||
class UnequipTrinketViewTest(TestCase):
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="gamer@test.io")
|
||
self.client.force_login(self.user)
|
||
self.token = Token.objects.filter(user=self.user, token_type=Token.COIN).first()
|
||
|
||
def test_get_returns_405(self):
|
||
from apps.lyric.models import Token
|
||
token = Token.objects.filter(user=self.user).first()
|
||
if token is None:
|
||
self.skipTest("No token for user")
|
||
response = self.client.get(reverse("unequip_trinket", kwargs={"token_id": token.pk}))
|
||
self.assertEqual(response.status_code, 405)
|
||
|
||
def test_post_clears_equipped_trinket_when_matching(self):
|
||
self.user.equipped_trinket = self.token
|
||
self.user.save(update_fields=["equipped_trinket"])
|
||
response = self.client.post(
|
||
reverse("unequip_trinket", kwargs={"token_id": self.token.pk})
|
||
)
|
||
self.assertEqual(response.status_code, 204)
|
||
self.user.refresh_from_db()
|
||
self.assertIsNone(self.user.equipped_trinket)
|
||
|
||
def test_post_ignores_non_matching_trinket(self):
|
||
"""POSTing a token that's not the currently-equipped one is a 204 no-op
|
||
— equipped_trinket is unchanged. Covers the implicit `else` of the
|
||
`if request.user.equipped_trinket_id == token.pk` branch."""
|
||
other_token = Token.objects.create(user=self.user, token_type=Token.TITHE)
|
||
self.user.equipped_trinket = self.token # COIN is equipped
|
||
self.user.save(update_fields=["equipped_trinket"])
|
||
response = self.client.post(
|
||
reverse("unequip_trinket", kwargs={"token_id": other_token.pk})
|
||
)
|
||
self.assertEqual(response.status_code, 204)
|
||
self.user.refresh_from_db()
|
||
self.assertEqual(self.user.equipped_trinket, self.token)
|
||
|
||
|
||
class GameKitViewTest(TestCase):
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="gamer@test.io")
|
||
self.client.force_login(self.user)
|
||
Applet.objects.get_or_create(slug="gk-trinkets", defaults={"name": "Trinkets", "context": "game-kit"})
|
||
Applet.objects.get_or_create(slug="gk-tokens", defaults={"name": "Tokens", "context": "game-kit"})
|
||
Applet.objects.get_or_create(slug="gk-decks", defaults={"name": "Card Decks", "context": "game-kit"})
|
||
Applet.objects.get_or_create(slug="gk-dice", defaults={"name": "Dice Sets", "context": "game-kit"})
|
||
Applet.objects.get_or_create(slug="pronouns", defaults={"name": "Pronouns", "context": "game-kit"})
|
||
response = self.client.get("/gameboard/game-kit/")
|
||
self.parsed = lxml.html.fromstring(response.content)
|
||
|
||
def test_game_kit_requires_login(self):
|
||
self.client.logout()
|
||
response = self.client.get("/gameboard/game-kit/")
|
||
self.assertRedirects(response, "/?next=/gameboard/game-kit/", fetch_redirect_response=False)
|
||
|
||
def test_game_kit_shows_gear_btn(self):
|
||
[_] = self.parsed.cssselect(".gear-btn")
|
||
|
||
def test_game_kit_shows_applet_menu(self):
|
||
[_] = self.parsed.cssselect("#id_game_kit_menu")
|
||
|
||
def test_game_kit_applet_menu_has_trinkets_checkbox(self):
|
||
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-trinkets']")
|
||
self.assertEqual(inp.get("type"), "checkbox")
|
||
|
||
def test_game_kit_applet_menu_has_tokens_checkbox(self):
|
||
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-tokens']")
|
||
self.assertEqual(inp.get("type"), "checkbox")
|
||
|
||
def test_game_kit_applet_menu_has_decks_checkbox(self):
|
||
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-decks']")
|
||
self.assertEqual(inp.get("type"), "checkbox")
|
||
|
||
def test_game_kit_applet_menu_has_dice_checkbox(self):
|
||
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-dice']")
|
||
self.assertEqual(inp.get("type"), "checkbox")
|
||
|
||
def test_game_kit_sections_container_present(self):
|
||
[_] = self.parsed.cssselect("#id_gk_sections_container")
|
||
|
||
def test_all_sections_visible_by_default(self):
|
||
sections = self.parsed.cssselect("#id_gk_sections_container section")
|
||
# Trinkets, Tokens, Card Decks, Dice Sets, Pronouns
|
||
self.assertEqual(len(sections), 5)
|
||
|
||
def test_pronouns_section_renders_five_cards(self):
|
||
[section] = self.parsed.cssselect("#id_gk_pronouns")
|
||
cards = section.cssselect(".gk-pronoun-card")
|
||
self.assertEqual(len(cards), 5)
|
||
slugs = [c.get("data-pronoun") for c in cards]
|
||
self.assertEqual(
|
||
slugs,
|
||
["pluralism", "bawlmorese", "misogyny", "misandry", "misanthropy"],
|
||
)
|
||
|
||
def test_pronouns_section_marks_current_choice_active(self):
|
||
# Default user pronouns = "pluralism" — that card should carry .active.
|
||
[active] = self.parsed.cssselect("#id_gk_pronouns .gk-pronoun-card.active")
|
||
self.assertEqual(active.get("data-pronoun"), "pluralism")
|
||
|
||
def test_game_kit_applet_menu_has_pronouns_checkbox(self):
|
||
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='pronouns']")
|
||
self.assertEqual(inp.get("type"), "checkbox")
|
||
|
||
def test_fan_stage_block_renders_rank_suit_chip_per_face(self):
|
||
"""Sprint A.7.5 — `#id_fan_stage_block` (the carousel modal's stat
|
||
block) gains the same `.stat-face-header` w. rank+suit chip + the
|
||
`.stat-face-title` + `.stat-face-arcana` slots that the my_sign /
|
||
sea_stage stat blocks have. Previously only the keyword list was
|
||
present (text-mode decks carried text on the card face); for image-
|
||
mode the stat block is the sole home for textual metadata."""
|
||
for face_cls in ("stat-face--upright", "stat-face--reversed"):
|
||
face = self.parsed.cssselect(f"#id_fan_stage_block .{face_cls}")
|
||
self.assertEqual(len(face), 1, f"expected one {face_cls}")
|
||
[header] = face[0].cssselect(".stat-face-header")
|
||
[_chip] = header.cssselect(".stat-face-chip")
|
||
[_rank] = header.cssselect(".stat-chip-rank")
|
||
[_icon] = header.cssselect("i.stat-chip-icon")
|
||
[_label] = header.cssselect(".stat-face-label")
|
||
[_title] = face[0].cssselect(".stat-face-title")
|
||
[_arcana] = face[0].cssselect(".stat-face-arcana")
|
||
|
||
|
||
class ToggleGameKitSectionsViewTest(TestCase):
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="gamer@test.io")
|
||
self.client.force_login(self.user)
|
||
self.trinkets, _ = Applet.objects.get_or_create(
|
||
slug="gk-trinkets", defaults={"name": "Trinkets", "context": "game-kit"}
|
||
)
|
||
self.tokens, _ = Applet.objects.get_or_create(
|
||
slug="gk-tokens", defaults={"name": "Tokens", "context": "game-kit"}
|
||
)
|
||
self.decks, _ = Applet.objects.get_or_create(
|
||
slug="gk-decks", defaults={"name": "Card Decks", "context": "game-kit"}
|
||
)
|
||
self.dice, _ = Applet.objects.get_or_create(
|
||
slug="gk-dice", defaults={"name": "Dice Sets", "context": "game-kit"}
|
||
)
|
||
self.url = reverse("toggle_game_kit_sections")
|
||
|
||
def test_unauthenticated_user_is_redirected(self):
|
||
self.client.logout()
|
||
response = self.client.post(self.url)
|
||
self.assertRedirects(response, f"/?next={self.url}", fetch_redirect_response=False)
|
||
|
||
def test_unchecked_section_gets_user_applet_with_visible_false(self):
|
||
self.client.post(self.url, {"applets": ["gk-trinkets"]})
|
||
ua = UserApplet.objects.get(user=self.user, applet=self.tokens)
|
||
self.assertFalse(ua.visible)
|
||
|
||
def test_redirects_on_normal_post(self):
|
||
response = self.client.post(self.url, {"applets": ["gk-trinkets", "gk-tokens"]})
|
||
self.assertRedirects(response, reverse("game_kit"), fetch_redirect_response=False)
|
||
|
||
def test_returns_200_on_htmx_post(self):
|
||
response = self.client.post(
|
||
self.url,
|
||
{"applets": ["gk-trinkets", "gk-tokens"]},
|
||
HTTP_HX_REQUEST="true",
|
||
)
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
def test_does_not_affect_gameboard_applets(self):
|
||
gb_applet, _ = Applet.objects.get_or_create(
|
||
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
||
)
|
||
self.client.post(self.url, {"applets": ["gk-trinkets"]})
|
||
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=gb_applet).exists())
|
||
|
||
def test_hidden_section_absent_from_htmx_response(self):
|
||
response = self.client.post(
|
||
self.url,
|
||
{"applets": ["gk-trinkets"]},
|
||
HTTP_HX_REQUEST="true",
|
||
)
|
||
parsed = lxml.html.fromstring(response.content)
|
||
sections = parsed.cssselect("section")
|
||
self.assertEqual(len(sections), 1)
|
||
|
||
|
||
class EquipTrinketViewTest(TestCase):
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="gamer@test.io")
|
||
self.client.force_login(self.user)
|
||
self.token = Token.objects.filter(user=self.user, token_type=Token.COIN).first()
|
||
|
||
def test_get_returns_trinket_button_partial(self):
|
||
response = self.client.get(
|
||
reverse("equip_trinket", kwargs={"token_id": self.token.pk})
|
||
)
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertTemplateUsed(response, "apps/gameboard/_partials/_equip_trinket_btn.html")
|
||
|
||
def test_post_equips_trinket_and_returns_204(self):
|
||
response = self.client.post(
|
||
reverse("equip_trinket", kwargs={"token_id": self.token.pk})
|
||
)
|
||
self.assertEqual(response.status_code, 204)
|
||
self.user.refresh_from_db()
|
||
self.assertEqual(self.user.equipped_trinket, self.token)
|
||
|
||
def test_post_requires_token_owner(self):
|
||
outsider = User.objects.create(email="outsider@test.io")
|
||
self.client.force_login(outsider)
|
||
response = self.client.post(
|
||
reverse("equip_trinket", kwargs={"token_id": self.token.pk})
|
||
)
|
||
# get_object_or_404 — the token belongs to self.user, not outsider
|
||
self.assertEqual(response.status_code, 404)
|
||
|
||
|
||
class TarotFanViewTest(TestCase):
|
||
def setUp(self):
|
||
from apps.epic.models import DeckVariant
|
||
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||
self.fiorentine = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
|
||
self.user = User.objects.create(email="fan@test.io")
|
||
self.client.force_login(self.user)
|
||
|
||
def test_returns_fan_partial_for_unlocked_deck(self):
|
||
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.earthman.pk}))
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertTemplateUsed(response, "apps/gameboard/_partials/_tarot_fan.html")
|
||
|
||
def test_returns_403_for_locked_deck(self):
|
||
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.fiorentine.pk}))
|
||
self.assertEqual(response.status_code, 403)
|
||
|
||
def test_text_mode_deck_keeps_text_scaffold(self):
|
||
"""Sprint A.7.5 — Earthman (has_card_images=False) carousel cards keep
|
||
the existing `.fan-card-corner` + `.fan-card-face` text scaffold and
|
||
lack the `.fan-card--image` marker. Pins the text-mode branch as the
|
||
before-state so the image-mode branch below isn't a regression risk."""
|
||
from apps.epic.models import TarotCard
|
||
# Cap at 5 cards to keep the test focused — the deck has 106 cards.
|
||
TarotCard.objects.filter(deck_variant=self.earthman).exclude(
|
||
pk__in=TarotCard.objects.filter(deck_variant=self.earthman)[:5]
|
||
).delete()
|
||
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.earthman.pk}))
|
||
parsed = lxml.html.fromstring(response.content)
|
||
cards = parsed.cssselect(".fan-card")
|
||
self.assertGreater(len(cards), 0)
|
||
for card in cards:
|
||
self.assertNotIn("fan-card--image", card.get("class", ""))
|
||
self.assertEqual(len(card.cssselect("img.sig-stage-card-img")), 0)
|
||
self.assertGreater(len(card.cssselect(".fan-card-corner")), 0)
|
||
self.assertGreater(len(card.cssselect(".fan-card-face")), 0)
|
||
|
||
def test_image_mode_deck_renders_img_per_card_and_drops_text_scaffold(self):
|
||
"""Sprint A.7.5 — Minchiate (has_card_images=True + non-polarized) cards
|
||
carry `.fan-card--image` + an `<img.sig-stage-card-img>` per card +
|
||
a `<img.sig-stage-card-back-img>` per card (since non-polarized; FLIP
|
||
flips to back). Text scaffold (corners + face) absent server-side."""
|
||
from apps.epic.models import DeckVariant
|
||
minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
|
||
self.user.unlocked_decks.add(minchiate)
|
||
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": minchiate.pk}))
|
||
self.assertEqual(response.status_code, 200)
|
||
parsed = lxml.html.fromstring(response.content)
|
||
cards = parsed.cssselect(".fan-card")
|
||
# Spot-check the first card; deck has 97 cards.
|
||
self.assertGreater(len(cards), 0)
|
||
first = cards[0]
|
||
self.assertIn("fan-card--image", first.get("class", ""))
|
||
self.assertEqual(first.get("data-arcana-key"), "MAJOR") # Minchiate trump #0 = Il Matto
|
||
[img] = first.cssselect("img.sig-stage-card-img")
|
||
self.assertIn("minchiate-fiorentine-1860-1890", img.get("src", ""))
|
||
[back_img] = first.cssselect("img.sig-stage-card-back-img")
|
||
self.assertIn(
|
||
"minchiate-fiorentine-1860-1890-back.png", back_img.get("src", "")
|
||
)
|
||
# Text scaffold absent across the WHOLE response — none of the cards
|
||
# in image-mode should render corners/face.
|
||
self.assertEqual(
|
||
len(parsed.cssselect(".fan-card-corner")), 0,
|
||
"image-mode cards must not render the text scaffold",
|
||
)
|
||
self.assertEqual(len(parsed.cssselect(".fan-card-face")), 0)
|
||
|
||
def test_image_mode_polarized_deck_omits_back_img(self):
|
||
"""Polarized image-equipped deck (none today, but the gate is
|
||
defensive): FLIP retains its polarity-cycle meaning and no back-img
|
||
renders. Earthman flipped to has_card_images=True simulates the
|
||
future state where Earthman art lands."""
|
||
self.earthman.has_card_images = True
|
||
self.earthman.save(update_fields=["has_card_images"])
|
||
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.earthman.pk}))
|
||
parsed = lxml.html.fromstring(response.content)
|
||
cards = parsed.cssselect(".fan-card")
|
||
self.assertGreater(len(cards), 0)
|
||
for card in cards:
|
||
self.assertIn("fan-card--image", card.get("class", ""))
|
||
self.assertEqual(
|
||
len(card.cssselect("img.sig-stage-card-back-img")), 0,
|
||
"Polarized deck must not render the back-image element",
|
||
)
|
||
|
||
|
||
class MySeaViewTest(TestCase):
|
||
"""Sprint 3 of the My Sea roadmap — standalone page is a shell only.
|
||
Sigs / sea-select / gatekeeper phase content lands in later sprints."""
|
||
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="sea@test.io")
|
||
self.client.force_login(self.user)
|
||
|
||
def test_my_sea_requires_login(self):
|
||
self.client.logout()
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertRedirects(
|
||
response, "/?next=/gameboard/my-sea/", fetch_redirect_response=False
|
||
)
|
||
|
||
def test_my_sea_renders_200(self):
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertTemplateUsed(response, "apps/gameboard/my_sea.html")
|
||
|
||
def test_my_sea_uses_gameboard_page_class(self):
|
||
# `page_class` drives the body class for the landscape layout aperture
|
||
# — My Sea inherits the gameboard's aperture (same nav/footer rails).
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertIn("page-gameboard", response.content.decode())
|
||
self.assertIn("page-my-sea", response.content.decode())
|
||
|
||
def test_sea_stage_stat_block_renders_rank_suit_chip_per_face(self):
|
||
"""Sprint A.7.5 — `_sea_stage.html` modal scaffold (included from
|
||
my_sea-picker-phase + the gameroom sea overlay) carries the new
|
||
`.stat-face-header` wrapper w. the rank+suit chip inline w. the
|
||
EMANATION/REVERSAL label. Both upright + reversed faces have their
|
||
own chip; stage-card.js populateStatExtras fills both identically
|
||
on each card focus. Rendered standalone via render_to_string since
|
||
the partial's parent views are phase-gated."""
|
||
from django.template.loader import render_to_string
|
||
html = render_to_string("apps/gameboard/_partials/_sea_stage.html")
|
||
parsed = lxml.html.fromstring(html)
|
||
for face_cls in ("stat-face--upright", "stat-face--reversed"):
|
||
face = parsed.cssselect(f".sea-stat-block .{face_cls}")
|
||
self.assertEqual(len(face), 1, f"expected one {face_cls}")
|
||
[header] = face[0].cssselect(".stat-face-header")
|
||
[chip] = header.cssselect(".stat-face-chip")
|
||
[_rank] = chip.cssselect(".stat-chip-rank")
|
||
[_icon] = chip.cssselect("i.stat-chip-icon")
|
||
[_label] = header.cssselect(".stat-face-label")
|
||
|
||
|
||
class MySeaDrawSeaLandingViewTest(TestCase):
|
||
"""Sprint 5 iter 1 — view context for the DRAW SEA landing UX. Pins
|
||
`no_equipped_deck` + `show_backup_intro_banner` context keys + the
|
||
presence of the new landing template elements when user passes the
|
||
Sprint 4b sign-gate."""
|
||
|
||
def setUp(self):
|
||
from apps.epic.models import personal_sig_cards
|
||
self.user = User.objects.create(email="draw@test.io")
|
||
self.client.force_login(self.user)
|
||
# Assign a sig so the view's landing branch (not the gate) renders.
|
||
self.user.significator = personal_sig_cards(self.user)[0]
|
||
self.user.save(update_fields=["significator"])
|
||
|
||
def test_context_no_equipped_deck_false_when_user_has_deck(self):
|
||
# post_save auto-equips Earthman; `no_equipped_deck` should be False.
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertFalse(response.context["no_equipped_deck"])
|
||
|
||
def test_context_no_equipped_deck_true_when_user_cleared_deck(self):
|
||
self.user.equipped_deck = None
|
||
self.user.save(update_fields=["equipped_deck"])
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertTrue(response.context["no_equipped_deck"])
|
||
|
||
def test_context_show_backup_intro_banner_when_no_deck_and_has_sig(self):
|
||
# Brief banner fires when user has a sig AND no deck — they're on the
|
||
# landing UX (gate passed) but headed for the backup-deck draw path.
|
||
self.user.equipped_deck = None
|
||
self.user.save(update_fields=["equipped_deck"])
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertTrue(response.context["show_backup_intro_banner"])
|
||
|
||
def test_context_show_backup_intro_banner_false_when_deck_equipped(self):
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertFalse(response.context["show_backup_intro_banner"])
|
||
|
||
def test_landing_renders_free_draw_btn_when_sig_set(self):
|
||
# Element ID `id_draw_sea_btn` describes intent (draw entry point);
|
||
# visible label is "FREE DRAW" for the daily-free quota draw.
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertContains(response, 'id="id_draw_sea_btn"')
|
||
self.assertContains(response, "FREE")
|
||
self.assertContains(response, "DRAW")
|
||
|
||
def test_landing_renders_six_chair_seats_with_C_suffix(self):
|
||
response = self.client.get(reverse("my_sea"))
|
||
html = response.content.decode()
|
||
for n in range(1, 7):
|
||
with self.subTest(slot=n):
|
||
self.assertIn(f'data-slot="{n}"', html)
|
||
self.assertIn(f"{n}C", html)
|
||
|
||
def test_landing_renders_position_status_ban_icon_on_each_seat(self):
|
||
# Each chair seat starts empty (red `.fa-ban` status icon). The
|
||
# FREE DRAW click handler swaps seat 1C's icon to .fa-circle-check
|
||
# client-side; this IT only pins the initial render state. Class
|
||
# substrings ("position-status-icon", "fa-ban") ALSO appear in the
|
||
# inline JS handler (classList.remove arg, querySelector arg) — so
|
||
# counts are asserted on the full class-attribute string only.
|
||
response = self.client.get(reverse("my_sea"))
|
||
html = response.content.decode()
|
||
self.assertEqual(html.count('class="position-status-icon fa-solid fa-ban"'), 6)
|
||
self.assertEqual(html.count('class="seat-position-label"'), 6)
|
||
|
||
def test_landing_not_rendered_when_user_has_no_sig(self):
|
||
# Sprint 4b gate still wins precedence — FREE DRAW must not render
|
||
# when significator is None.
|
||
self.user.significator = None
|
||
self.user.save(update_fields=["significator"])
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertNotContains(response, 'id="id_draw_sea_btn"')
|
||
|
||
|
||
class MySeaPickerPhaseTemplateTest(TestCase):
|
||
"""Sprint 5 iter 2 — picker-phase template render contract: the
|
||
three-card cross (sig in core + cover/leave/loom drop zones) is
|
||
server-rendered (hidden until JS swaps data-phase after FREE DRAW).
|
||
Crown / lay / cross from the gameroom's 6-position Celtic Cross are
|
||
deliberately forsaken in the solo flow."""
|
||
|
||
def setUp(self):
|
||
from apps.epic.models import personal_sig_cards
|
||
self.user = User.objects.create(email="picker@test.io")
|
||
self.client.force_login(self.user)
|
||
self.target = personal_sig_cards(self.user)[0]
|
||
self.user.significator = self.target
|
||
self.user.save(update_fields=["significator"])
|
||
|
||
def test_picker_renders_significator_in_core_cell(self):
|
||
response = self.client.get(reverse("my_sea"))
|
||
html = response.content.decode()
|
||
# Sig card carries the user's significator id so iter 3's draw
|
||
# flow can target it for SPIN / FLIP / FYI without re-fetching.
|
||
self.assertIn('sea-pos-core', html)
|
||
self.assertIn('sea-sig-card', html)
|
||
self.assertIn(f'data-card-id="{self.target.id}"', html)
|
||
|
||
def test_picker_renders_cover_leave_loom_positions(self):
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertContains(response, "sea-pos-cover")
|
||
self.assertContains(response, "sea-pos-leave")
|
||
self.assertContains(response, "sea-pos-loom")
|
||
|
||
def test_sea_sig_card_renders_image_when_deck_has_card_images(self):
|
||
"""Sprint A.5 — central sig card on /gameboard/my-sea/ carries the
|
||
`.sig-stage-card--image` marker class + an <img.sig-stage-card-img>
|
||
child pointing at the deck's image asset when the user's equipped
|
||
deck is image-equipped (Minchiate today). Mirrors A.3's my_sign.html
|
||
image-mode treatment so the central sig + the Sea Stage modal render
|
||
with consistent visual identity."""
|
||
from apps.epic.models import DeckVariant, TarotCard
|
||
minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
|
||
# Override the auto-equipped Earthman w. Minchiate + pick Il Matto as
|
||
# the user's sig (it's MAJOR rank 0 → permitted by personal_sig_cards
|
||
# IF user has the super-nomad Note unlock; superuser auto-gets it).
|
||
self.user.is_superuser = True
|
||
self.user.save()
|
||
# Re-run the post_save Note grants for the now-superuser by manually
|
||
# granting (signal only fires on initial create).
|
||
from apps.drama.models import Note
|
||
Note.grant_if_new(self.user, "super-nomad")
|
||
Note.grant_if_new(self.user, "super-schizo")
|
||
self.user.unlocked_decks.add(minchiate)
|
||
self.user.equipped_deck = minchiate
|
||
il_matto = TarotCard.objects.get(deck_variant=minchiate, slug="il-matto")
|
||
self.user.significator = il_matto
|
||
self.user.save(update_fields=["equipped_deck", "significator"])
|
||
|
||
import lxml.html
|
||
response = self.client.get(reverse("my_sea"))
|
||
parsed = lxml.html.fromstring(response.content)
|
||
[sig_card] = parsed.cssselect(".sea-sig-card")
|
||
self.assertIn(
|
||
"sig-stage-card--image", sig_card.get("class", ""),
|
||
"Sig card must carry --image marker class for Minchiate-equipped user",
|
||
)
|
||
[img] = sig_card.cssselect("img.sig-stage-card-img")
|
||
self.assertIn(
|
||
"minchiate-fiorentine-1860-1890-trumps-00-il-matto.png",
|
||
img.get("src", ""),
|
||
"Image src must point at the v2-convention Il Matto asset",
|
||
)
|
||
|
||
def test_sea_sig_card_renders_text_when_deck_has_no_images(self):
|
||
"""Earthman (has_card_images=False) keeps the existing corner-rank +
|
||
suit-icon text render — the image branch only applies to image-decks."""
|
||
# Default setUp leaves the user on auto-equipped Earthman.
|
||
import lxml.html
|
||
response = self.client.get(reverse("my_sea"))
|
||
parsed = lxml.html.fromstring(response.content)
|
||
[sig_card] = parsed.cssselect(".sea-sig-card")
|
||
self.assertNotIn("sig-stage-card--image", sig_card.get("class", ""))
|
||
self.assertEqual(
|
||
len(sig_card.cssselect("img.sig-stage-card-img")), 0,
|
||
"Non-image deck must not render the <img> in the sig card",
|
||
)
|
||
self.assertEqual(
|
||
len(sig_card.cssselect(".fan-corner-rank")), 1,
|
||
"Non-image deck falls through to corner-rank text render",
|
||
)
|
||
|
||
def test_picker_renders_six_card_only_positions_for_spread_switch(self):
|
||
# Crown / lay / cross sit in the DOM unconditionally so iter 3's
|
||
# SPREAD dropdown can reveal them via CSS attribute swap (data-
|
||
# spread-shape="six-card") without re-rendering. Default 3-card
|
||
# spread hides them via `.my-sea-cross[data-spread-shape=
|
||
# "three-card"]` rules in _gameboard.scss — FT pins the hidden
|
||
# state visually.
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertContains(response, "sea-pos-crown")
|
||
self.assertContains(response, "sea-pos-lay")
|
||
self.assertContains(response, "sea-pos-cross")
|
||
|
||
def test_picker_not_rendered_when_user_has_no_sig(self):
|
||
# 4b gate wins; picker has no business rendering without a sig.
|
||
self.user.significator = None
|
||
self.user.save(update_fields=["significator"])
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertNotContains(response, "my-sea-picker")
|
||
|
||
|
||
class MySeaPolarityMatchesMySignTest(TestCase):
|
||
"""Bug 2026-05-21 (user-reported): a levity sig chosen on /billboard/
|
||
my-sign/ rendered as gravity-styled on /gameboard/my-sea/ (priUser bg
|
||
+ secUser text) — and vice versa for gravity sigs (which then collided
|
||
w. the hardcoded --secUser corner-rank color in `.sea-sig-card`,
|
||
making the rank + suit-icon invisible against a --secUser bg).
|
||
|
||
Root cause was a polarity inversion in `my_sea.html:10` —
|
||
`{% if significator_reversed %}gravity{% else %}levity{% endif %}` —
|
||
opposite of `my_sign.html:22` + its JS `_polarity()`. This test pins
|
||
the two surfaces to agree on the same `User.significator_reversed`
|
||
→ `data-polarity` mapping, so any future drift gets caught."""
|
||
|
||
def setUp(self):
|
||
from apps.epic.models import personal_sig_cards
|
||
self.user = User.objects.create(email="polarity@test.io")
|
||
self.client.force_login(self.user)
|
||
target = personal_sig_cards(self.user)[0]
|
||
self.user.significator = target
|
||
self.user.save(update_fields=["significator"])
|
||
|
||
def test_unreversed_sig_renders_gravity_on_both_surfaces(self):
|
||
"""`significator_reversed=False` (the on-creation default) renders
|
||
data-polarity="gravity" on BOTH my_sign + my_sea — the convention
|
||
established by my_sign's Sprint 4a picker + its `_polarity()` JS."""
|
||
self.user.significator_reversed = False
|
||
self.user.save(update_fields=["significator_reversed"])
|
||
sea = self.client.get(reverse("my_sea")).content.decode()
|
||
sign = self.client.get(reverse("billboard:my_sign")).content.decode()
|
||
self.assertIn('data-polarity="gravity"', sea)
|
||
self.assertIn('data-polarity="gravity"', sign)
|
||
|
||
def test_reversed_sig_renders_levity_on_both_surfaces(self):
|
||
"""`significator_reversed=True` (FLIP-toggled on my_sign) renders
|
||
data-polarity="levity" on BOTH surfaces."""
|
||
self.user.significator_reversed = True
|
||
self.user.save(update_fields=["significator_reversed"])
|
||
sea = self.client.get(reverse("my_sea")).content.decode()
|
||
sign = self.client.get(reverse("billboard:my_sign")).content.decode()
|
||
self.assertIn('data-polarity="levity"', sea)
|
||
self.assertIn('data-polarity="levity"', sign)
|
||
|
||
|
||
class MySeaSpreadFormTemplateTest(TestCase):
|
||
"""Sprint 5 iter 3 — form col + SPREAD dropdown structure + default-
|
||
spread context + cross's `data-spread-shape` attribute. Iter 3 spec
|
||
locks `Situation, Action, Outcome` as the default spread (a 3-card
|
||
variant); the 6 spreads sit under 2 section dividers (3-card / 6-
|
||
card)."""
|
||
|
||
SPREAD_OPTIONS = [
|
||
("past-present-future", "Past, Present, Future"),
|
||
("situation-action-outcome", "Situation, Action, Outcome"),
|
||
("mind-body-spirit", "Mind, Body, Spirit"),
|
||
("desire-obstacle-solution", "Desire, Obstacle, Solution"),
|
||
("waite-smith", "Celtic Cross, Waite-Smith"),
|
||
("escape-velocity", "Celtic Cross, Escape Velocity"),
|
||
]
|
||
|
||
def setUp(self):
|
||
from apps.epic.models import personal_sig_cards
|
||
self.user = User.objects.create(email="spread@test.io")
|
||
self.client.force_login(self.user)
|
||
self.target = personal_sig_cards(self.user)[0]
|
||
self.user.significator = self.target
|
||
self.user.save(update_fields=["significator"])
|
||
|
||
def test_context_default_spread_is_situation_action_outcome(self):
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertEqual(
|
||
response.context["default_spread"], "situation-action-outcome",
|
||
)
|
||
|
||
def test_context_reversals_pct_defaults_to_25(self):
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertEqual(response.context["reversals_pct"], 25)
|
||
|
||
def test_template_renders_all_six_spread_options(self):
|
||
response = self.client.get(reverse("my_sea"))
|
||
html = response.content.decode()
|
||
for value, label in self.SPREAD_OPTIONS:
|
||
with self.subTest(spread=value):
|
||
self.assertIn(f'data-value="{value}"', html)
|
||
self.assertIn(label, html)
|
||
|
||
def test_template_renders_three_card_and_six_card_section_dividers(self):
|
||
response = self.client.get(reverse("my_sea"))
|
||
html = response.content.decode()
|
||
self.assertEqual(html.count("sea-select-divider"), 2)
|
||
self.assertIn("3-card spreads", html)
|
||
self.assertIn("6-card spreads", html)
|
||
|
||
def test_template_marks_situation_action_outcome_aria_selected(self):
|
||
response = self.client.get(reverse("my_sea"))
|
||
html = response.content.decode()
|
||
# The default option carries aria-selected="true"; the others false.
|
||
self.assertIn(
|
||
'data-value="situation-action-outcome" aria-selected="true"', html,
|
||
)
|
||
|
||
def test_cross_carries_initial_data_spread_sao(self):
|
||
# `.my-sea-cross[data-spread]` is the per-spread visibility key;
|
||
# default-spread context value renders into the attribute. SCSS
|
||
# rules in _gameboard.scss hide the inactive positions per spread.
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertContains(response, 'data-spread="situation-action-outcome"')
|
||
|
||
def test_template_renders_sao_position_labels_on_default(self):
|
||
# Server-renders the SAO position labels into the empty drop-zone
|
||
# `.sea-pos-label` spans so the page is correct before JS boots.
|
||
# JS swaps labels on spread change.
|
||
response = self.client.get(reverse("my_sea"))
|
||
html = response.content.decode()
|
||
self.assertIn('data-position="lay">Situation</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_action_btn_del_and_reversal_hint(self):
|
||
# Iter 4c — LOCK HAND replaced by AUTO DRAW (mid-draw) which JS
|
||
# transitions to GATE VIEW on completion. ID `id_sea_action_btn`
|
||
# is the single slot housing both states (label + `data-state`
|
||
# toggled by JS). User w. no active draw → AUTO DRAW label.
|
||
response = self.client.get(reverse("my_sea"))
|
||
html = response.content.decode()
|
||
self.assertIn("sea-deck-stack--gravity", html)
|
||
self.assertIn("sea-deck-stack--levity", html)
|
||
self.assertIn('id="id_sea_action_btn"', html)
|
||
self.assertIn('data-state="auto-draw"', html)
|
||
self.assertIn("AUTO", html)
|
||
self.assertIn('id="id_sea_del"', html)
|
||
self.assertIn("sea-reversal-hint", html)
|
||
self.assertIn("25% reversals", html)
|
||
|
||
|
||
class MySeaDeckDataViewTest(TestCase):
|
||
"""Sprint 5 iter 4a — view-level deck-data contract. `my_sea` view
|
||
embeds a shuffled deck (levity + gravity halves, current user's
|
||
significator excluded, reversal pre-rolled at ~25%) as JSON via
|
||
the `sea_deck_data` context key + `{{ ...|json_script }}` filter
|
||
in the template."""
|
||
|
||
def setUp(self):
|
||
from apps.epic.models import personal_sig_cards
|
||
self.user = User.objects.create(email="deck@test.io")
|
||
self.client.force_login(self.user)
|
||
self.target = personal_sig_cards(self.user)[0]
|
||
self.user.significator = self.target
|
||
self.user.save(update_fields=["significator"])
|
||
|
||
def test_context_sea_deck_data_has_two_polarity_halves(self):
|
||
response = self.client.get(reverse("my_sea"))
|
||
deck = response.context["sea_deck_data"]
|
||
self.assertIn("levity", deck)
|
||
self.assertIn("gravity", deck)
|
||
self.assertIsInstance(deck["levity"], list)
|
||
self.assertIsInstance(deck["gravity"], list)
|
||
|
||
def test_deck_data_excludes_user_significator(self):
|
||
response = self.client.get(reverse("my_sea"))
|
||
deck = response.context["sea_deck_data"]
|
||
all_ids = (
|
||
{c["id"] for c in deck["levity"]}
|
||
| {c["id"] for c in deck["gravity"]}
|
||
)
|
||
self.assertNotIn(self.target.id, all_ids)
|
||
|
||
def test_deck_data_halves_are_disjoint(self):
|
||
response = self.client.get(reverse("my_sea"))
|
||
deck = response.context["sea_deck_data"]
|
||
levity_ids = {c["id"] for c in deck["levity"]}
|
||
gravity_ids = {c["id"] for c in deck["gravity"]}
|
||
self.assertEqual(levity_ids & gravity_ids, set())
|
||
|
||
def test_deck_data_cards_carry_corner_rank_suit_icon_and_reversed(self):
|
||
# Card dict shape mirrors the gameroom `sea_deck` endpoint so
|
||
# iter 4b's persistence/render path can reuse the JSON contract.
|
||
response = self.client.get(reverse("my_sea"))
|
||
deck = response.context["sea_deck_data"]
|
||
any_card = (deck["levity"] + deck["gravity"])[0]
|
||
for key in ("id", "corner_rank", "suit_icon", "reversed"):
|
||
with self.subTest(key=key):
|
||
self.assertIn(key, any_card)
|
||
self.assertIsInstance(any_card["reversed"], bool)
|
||
|
||
def test_template_embeds_deck_as_json_script(self):
|
||
# Embed mechanism: `{{ sea_deck_data|json_script:"id_my_sea_deck" }}`
|
||
# gives a `<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_first_card_sets_user_last_free_draw_at(self):
|
||
# User-spec 2026-05-23: free-draw cooldown is anchored to User.
|
||
# last_free_draw_at, set on the first-card-of-cycle lock so the
|
||
# next FREE DRAW unlocks 24h later regardless of any intervening
|
||
# PAID DRAWs. Fresh user (no prior cooldown) → SET.
|
||
import json
|
||
before = timezone.now()
|
||
self.assertIsNone(self.user.last_free_draw_at,
|
||
"precondition: fresh user has no prior free draw")
|
||
response = self.client.post(
|
||
self.url, data=json.dumps(self._build_payload()),
|
||
content_type="application/json",
|
||
)
|
||
self.assertEqual(response.status_code, 200)
|
||
self.user.refresh_from_db()
|
||
self.assertIsNotNone(self.user.last_free_draw_at)
|
||
self.assertGreaterEqual(self.user.last_free_draw_at, before)
|
||
|
||
def test_lock_post_during_cooldown_does_not_reset_last_free_draw_at(self):
|
||
# User is mid-cooldown (last_free_draw_at set to 6h ago). A
|
||
# subsequent /lock POST (e.g. a paid draw committing its first
|
||
# card) must NOT bump last_free_draw_at — the cooldown stays
|
||
# anchored to the original FREE DRAW per user-spec 2026-05-23.
|
||
import json
|
||
from datetime import timedelta
|
||
from apps.gameboard.models import MySeaDraw
|
||
original_anchor = timezone.now() - timedelta(hours=6)
|
||
self.user.last_free_draw_at = original_anchor
|
||
self.user.save(update_fields=["last_free_draw_at"])
|
||
# Seed an existing row in a paid-through state (no hand yet).
|
||
MySeaDraw.objects.create(
|
||
user=self.user, spread="situation-action-outcome",
|
||
significator_id=self.target.id, hand=[],
|
||
paid_through_at=timezone.now(),
|
||
)
|
||
# Now lock the first paid card.
|
||
self.client.post(
|
||
self.url, data=json.dumps(self._build_payload(hand=[{
|
||
"position": "lay", "card_id": self.target.id,
|
||
"reversed": False, "polarity": "gravity",
|
||
}])),
|
||
content_type="application/json",
|
||
)
|
||
self.user.refresh_from_db()
|
||
# last_free_draw_at unchanged — within 1s of original anchor.
|
||
delta = abs((self.user.last_free_draw_at - original_anchor).total_seconds())
|
||
self.assertLess(delta, 1.0)
|
||
|
||
def test_lock_post_first_paid_card_consumes_paid_through_credit(self):
|
||
# User-spec 2026-05-23: paid_through credit is one-shot. The
|
||
# first card drawn after PAID DRAW commit clears `paid_through_
|
||
# at` so the next redraw requires a fresh gatekeeper deposit.
|
||
import json
|
||
from apps.gameboard.models import MySeaDraw
|
||
self.user.last_free_draw_at = timezone.now() - timezone.timedelta(hours=6)
|
||
self.user.save(update_fields=["last_free_draw_at"])
|
||
row = MySeaDraw.objects.create(
|
||
user=self.user, spread="situation-action-outcome",
|
||
significator_id=self.target.id, hand=[],
|
||
paid_through_at=timezone.now(),
|
||
)
|
||
self.client.post(
|
||
self.url, data=json.dumps(self._build_payload(hand=[{
|
||
"position": "lay", "card_id": self.target.id,
|
||
"reversed": False, "polarity": "gravity",
|
||
}])),
|
||
content_type="application/json",
|
||
)
|
||
row.refresh_from_db()
|
||
self.assertIsNone(row.paid_through_at,
|
||
"first card of paid session must consume the paid_through credit")
|
||
self.assertEqual(len(row.hand), 1)
|
||
|
||
def test_lock_post_within_quota_upserts_same_row(self):
|
||
# Iter 4c — `/lock` is now an upsert (per-placement POST cadence).
|
||
# Second POST w. same spread updates the existing row's hand
|
||
# rather than 409'ing. Only one row exists per user per 24h.
|
||
import json
|
||
from apps.gameboard.models import MySeaDraw
|
||
from apps.epic.models import TarotCard
|
||
# First POST: 1-card partial hand
|
||
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
|
||
partial = {
|
||
"spread": "situation-action-outcome",
|
||
"hand": [{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"}],
|
||
}
|
||
r1 = self.client.post(
|
||
self.url, data=json.dumps(partial),
|
||
content_type="application/json",
|
||
)
|
||
self.assertEqual(r1.status_code, 200)
|
||
# Second POST: full 3-card hand (the SAME draw progressing).
|
||
full = self._build_payload()
|
||
r2 = self.client.post(
|
||
self.url, data=json.dumps(full),
|
||
content_type="application/json",
|
||
)
|
||
self.assertEqual(r2.status_code, 200)
|
||
# Exactly one MySeaDraw row exists; hand is the latest full one.
|
||
rows = MySeaDraw.objects.filter(user=self.user)
|
||
self.assertEqual(rows.count(), 1)
|
||
self.assertEqual(len(rows.first().hand), 3)
|
||
|
||
def test_lock_post_spread_mismatch_within_quota_returns_409(self):
|
||
# Spread is committed at first-card moment for the duration of an
|
||
# ACTIVE non-empty draw. Switching spread mid-non-empty-draw → 409.
|
||
import json
|
||
from apps.epic.models import TarotCard
|
||
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
|
||
self.client.post(
|
||
self.url, data=json.dumps({
|
||
"spread": "situation-action-outcome",
|
||
"hand": [{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"}],
|
||
}),
|
||
content_type="application/json",
|
||
)
|
||
response = self.client.post(
|
||
self.url, data=json.dumps({
|
||
"spread": "waite-smith",
|
||
"hand": [{"position": "crown", "card_id": cards[1].id, "reversed": False, "polarity": "levity"}],
|
||
}),
|
||
content_type="application/json",
|
||
)
|
||
self.assertEqual(response.status_code, 409)
|
||
|
||
def test_lock_post_spread_switch_after_del_succeeds(self):
|
||
"""Sprint A.7-polish-3 — once the user DELs (clears hand to []), the
|
||
spread lock lifts: a subsequent POST with a different spread
|
||
UPDATES the existing row's spread + populates the new hand. The 24h
|
||
quota window (created_at + paid_through_at) is preserved — only the
|
||
spread field changes. Fixes the AUTO-DRAW-only-works-on-SAO user bug
|
||
report (2026-05-25 PM): users couldn't switch spreads even after
|
||
DELing because the row's spread stayed locked for the full 24h."""
|
||
import json
|
||
from apps.epic.models import TarotCard
|
||
from apps.gameboard.models import MySeaDraw
|
||
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
|
||
# First draw under SAO — creates row w. spread=SAO + 1 card.
|
||
self.client.post(
|
||
self.url, data=json.dumps({
|
||
"spread": "situation-action-outcome",
|
||
"hand": [{"position": "lay", "card_id": cards[0].id,
|
||
"reversed": False, "polarity": "gravity"}],
|
||
}),
|
||
content_type="application/json",
|
||
)
|
||
row = MySeaDraw.objects.get(user=self.user)
|
||
self.assertEqual(row.spread, "situation-action-outcome")
|
||
original_created_at = row.created_at
|
||
# DEL clears hand to [] but preserves the row.
|
||
self.client.post(reverse("my_sea_delete"))
|
||
row.refresh_from_db()
|
||
self.assertEqual(row.hand, [])
|
||
self.assertEqual(row.spread, "situation-action-outcome")
|
||
# Now POST a draw under a DIFFERENT spread. Should succeed +
|
||
# update the row's spread.
|
||
response = self.client.post(
|
||
self.url, data=json.dumps({
|
||
"spread": "waite-smith",
|
||
"hand": [{"position": "crown", "card_id": cards[1].id,
|
||
"reversed": False, "polarity": "levity"}],
|
||
}),
|
||
content_type="application/json",
|
||
)
|
||
self.assertEqual(response.status_code, 200)
|
||
row.refresh_from_db()
|
||
self.assertEqual(row.spread, "waite-smith")
|
||
self.assertEqual(len(row.hand), 1)
|
||
# The 24h quota window is preserved — created_at unchanged.
|
||
self.assertEqual(row.created_at, original_created_at)
|
||
|
||
def test_lock_post_returns_hand_complete_flag(self):
|
||
# Body includes `hand_complete` so the JS can decide whether to
|
||
# transition the picker into post-completion state (DEL enable,
|
||
# FLIPs disable, AUTO DRAW → GATE VIEW).
|
||
import json
|
||
partial = {
|
||
"spread": "situation-action-outcome",
|
||
"hand": self._build_payload()["hand"][:1],
|
||
}
|
||
r1 = self.client.post(
|
||
self.url, data=json.dumps(partial),
|
||
content_type="application/json",
|
||
)
|
||
self.assertFalse(r1.json()["hand_complete"])
|
||
r2 = self.client.post(
|
||
self.url, data=json.dumps(self._build_payload()),
|
||
content_type="application/json",
|
||
)
|
||
self.assertTrue(r2.json()["hand_complete"])
|
||
|
||
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 4c — POST `/gameboard/my-sea/delete` clears the HAND
|
||
but preserves the row so the 24h quota window keeps running."""
|
||
|
||
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_hand_but_preserves_row(self):
|
||
# Iter 4c — DEL no longer deletes; the row stays as a 24h quota
|
||
# tracker. Hand JSON gets wiped + `created_at` preserved (so the
|
||
# landing renders GATE VIEW, not FREE DRAW, until the row expires).
|
||
from apps.gameboard.models import MySeaDraw
|
||
original_created_at = self.draw.created_at
|
||
response = self.client.post(self.url)
|
||
self.assertIn(response.status_code, (200, 204, 302))
|
||
self.draw.refresh_from_db()
|
||
self.assertEqual(self.draw.hand, [])
|
||
self.assertEqual(self.draw.created_at, original_created_at)
|
||
self.assertTrue(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)
|
||
other_draw.refresh_from_db()
|
||
self.assertEqual(len(other_draw.hand), 3) # untouched
|
||
|
||
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.
|
||
# First DEL clears hand. Second DEL finds the row w. empty hand
|
||
# already; just no-ops.
|
||
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_triggers_brief_banner_when_active_draw_exists(self):
|
||
# Brief is rendered client-side via Brief.showBanner (standard
|
||
# `.note-banner` w. Gaussian-glass bg, portaled atop the h2 —
|
||
# same UX as my-notes / my-sign default-deck-warning Briefs).
|
||
# Server emits a `window._showFreeDrawLockedBrief("<iso>")` call
|
||
# gated on active_draw; ISO timestamp (`|date:'c'`) is re-used
|
||
# as both `created_at` AND the source for the human-formatted
|
||
# display string note.js renders in the `.note-banner__timestamp`
|
||
# slot — single source of truth, no "Invalid Date" on bad input.
|
||
response = self.client.get(reverse("my_sea"))
|
||
# Match the call form w. opening quote — the bare token
|
||
# `_showFreeDrawLockedBrief(` also appears in the function
|
||
# definition emitted unconditionally inside the picker IIFE.
|
||
self.assertContains(response, 'window._showFreeDrawLockedBrief("')
|
||
# The ISO format produced by Django's `|date:'c'` starts with the
|
||
# full year + ISO-style T separator — pin a representative token.
|
||
from django.utils import timezone
|
||
from datetime import timedelta
|
||
expected_year = (timezone.now() + timedelta(hours=24)).strftime("%Y")
|
||
self.assertContains(response, '_showFreeDrawLockedBrief("' + expected_year)
|
||
|
||
def test_view_does_not_trigger_brief_banner_without_active_draw(self):
|
||
# Definition of `_showFreeDrawLockedBrief` is always emitted;
|
||
# only the CALL is gated on active_draw. Pin the call form.
|
||
from apps.gameboard.models import MySeaDraw
|
||
MySeaDraw.objects.all().delete()
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertNotContains(response, 'window._showFreeDrawLockedBrief("')
|
||
|
||
def test_view_wires_del_button_to_shared_guard_portal_when_active_draw(self):
|
||
# No my-sea-specific guard markup — the picker IIFE calls
|
||
# `window.showGuard(delBtn, "Are you sure?", confirmFn)` which
|
||
# targets the shared #id_guard_portal from base.html (same
|
||
# tooltip the room gear-menu uses; standard OK/NVM button pair).
|
||
# Server emits the call site; we pin the call form + the delete
|
||
# URL it POSTs to.
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertContains(response, "window.showGuard(")
|
||
self.assertContains(response, reverse("my_sea_delete"))
|
||
|
||
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"')
|
||
|
||
def test_complete_hand_renders_action_btn_as_gate_view(self):
|
||
# Iter 4c — server pre-renders the action btn label + data-state
|
||
# based on `hand_complete`. With the setUp's 3-card SAO hand,
|
||
# hand is complete → btn label is GATE VIEW.
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertContains(response, 'data-state="gate-view"')
|
||
self.assertContains(response, "GATE")
|
||
self.assertContains(response, "VIEW")
|
||
|
||
def test_complete_hand_picker_carries_locked_class(self):
|
||
# `.my-sea-picker--locked` is server-rendered for completed hands
|
||
# so the JS init seeds `_locked=true` w.o. waiting for the post-
|
||
# placement state transition (matters for hot reloads, bfcache).
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertContains(response, "my-sea-picker--locked")
|
||
|
||
def test_complete_hand_del_btn_is_not_disabled(self):
|
||
# DEL is `.btn-disabled` only when hand is INCOMPLETE. Complete
|
||
# hand → DEL renders w.o. the disabled class (clicking opens the
|
||
# guard portal).
|
||
import re
|
||
response = self.client.get(reverse("my_sea"))
|
||
html = response.content.decode()
|
||
m = re.search(
|
||
r'<button[^>]*id="id_sea_del"[^>]*class="([^"]*)"', html,
|
||
)
|
||
self.assertIsNotNone(m)
|
||
self.assertNotIn("btn-disabled", m.group(1))
|
||
|
||
|
||
class MySeaViewWithEmptyHandTest(TestCase):
|
||
"""Sprint 5 iter 4c — view branch for an active draw w. empty hand
|
||
(the post-DEL state, where the quota row stays as a 24h tracker but
|
||
the user's hand has been wiped). Landing renders w. GATE VIEW (NOT
|
||
FREE DRAW) as the primary nav."""
|
||
|
||
def setUp(self):
|
||
from apps.epic.models import personal_sig_cards
|
||
from apps.gameboard.models import MySeaDraw
|
||
self.user = User.objects.create(email="empty@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"])
|
||
# Active draw row but hand is empty — simulates the post-DEL state.
|
||
self.draw = MySeaDraw.objects.create(
|
||
user=self.user, spread="situation-action-outcome",
|
||
significator_id=self.target.id, hand=[],
|
||
)
|
||
|
||
def test_empty_hand_renders_landing_phase_not_picker(self):
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertContains(response, 'data-phase="landing"')
|
||
|
||
def test_empty_hand_landing_renders_gate_view_btn_not_free_draw(self):
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertContains(response, 'id="id_my_sea_gate_view_btn"')
|
||
self.assertNotContains(response, 'id="id_draw_sea_btn"')
|
||
|
||
def test_empty_hand_gate_view_btn_links_to_gate_url(self):
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertContains(response, reverse("my_sea_gate"))
|
||
|
||
def test_empty_hand_brief_banner_still_triggered(self):
|
||
# Quota's still committed (row exists, 24h clock still running) →
|
||
# the Brief banner is part of the saved-draw context, regardless
|
||
# of hand state. Informs the user when the next free draw is.
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertContains(response, 'window._showFreeDrawLockedBrief("')
|
||
|
||
|
||
class MySeaViewWithPartialHandTest(TestCase):
|
||
"""Sprint 5 iter 4c — view branch for an active draw w. mid-progress
|
||
hand (some slots filled, not yet complete). Picker renders w. the
|
||
partial slots + AUTO DRAW btn (not GATE VIEW); DEL stays disabled."""
|
||
|
||
def setUp(self):
|
||
from apps.epic.models import personal_sig_cards, TarotCard
|
||
from apps.gameboard.models import MySeaDraw
|
||
self.user = User.objects.create(email="partial@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)[:2])
|
||
# SAO is a 3-position spread; partial = 2 cards drawn.
|
||
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"},
|
||
],
|
||
)
|
||
|
||
def test_partial_hand_renders_picker_phase(self):
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertContains(response, 'data-phase="picker"')
|
||
|
||
def test_partial_hand_action_btn_is_auto_draw_not_gate_view(self):
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertContains(response, 'data-state="auto-draw"')
|
||
self.assertContains(response, "AUTO")
|
||
|
||
def test_partial_hand_del_btn_carries_btn_disabled(self):
|
||
import re
|
||
response = self.client.get(reverse("my_sea"))
|
||
html = response.content.decode()
|
||
m = re.search(
|
||
r'<button[^>]*id="id_sea_del"[^>]*class="([^"]*)"', html,
|
||
)
|
||
self.assertIsNotNone(m)
|
||
self.assertIn("btn-disabled", m.group(1))
|
||
|
||
def test_partial_hand_picker_does_NOT_carry_locked_class(self):
|
||
# Hand is mid-progress; locked class only applies on completion.
|
||
import re
|
||
response = self.client.get(reverse("my_sea"))
|
||
html = response.content.decode()
|
||
m = re.search(r'<div class="my-sea-picker([^"]*)"', html)
|
||
self.assertIsNotNone(m)
|
||
self.assertNotIn("my-sea-picker--locked", m.group(1))
|
||
|
||
|
||
class MySeaGateViewTest(TestCase):
|
||
"""Sprint 6 iter 6a — `my_sea_gate` renders the solo gatekeeper UI.
|
||
Replaces the iter-4c 404 stub. Branches on whether a deposit is
|
||
already reserved on the user's MySeaDraw row."""
|
||
|
||
def setUp(self):
|
||
from apps.epic.models import personal_sig_cards
|
||
from datetime import timedelta
|
||
from apps.lyric.models import Token
|
||
self.user = User.objects.create(email="gate@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"])
|
||
# Seed a FREE token so deposit attempts have something to pick.
|
||
Token.objects.create(
|
||
user=self.user, token_type=Token.FREE,
|
||
expires_at=timezone.now() + timedelta(days=30),
|
||
)
|
||
|
||
def test_gate_view_requires_login(self):
|
||
self.client.logout()
|
||
response = self.client.get(reverse("my_sea_gate"))
|
||
self.assertEqual(response.status_code, 302)
|
||
|
||
def test_gate_view_renders_200(self):
|
||
response = self.client.get(reverse("my_sea_gate"))
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertTemplateUsed(response, "apps/gameboard/my_sea_gate.html")
|
||
|
||
def test_gate_view_shows_insert_token_form_when_no_deposit(self):
|
||
from apps.gameboard.models import MySeaDraw
|
||
MySeaDraw.objects.create(
|
||
user=self.user, spread="situation-action-outcome",
|
||
significator_id=self.target.id, hand=[],
|
||
)
|
||
response = self.client.get(reverse("my_sea_gate"))
|
||
self.assertContains(response, reverse("my_sea_insert_token"))
|
||
self.assertNotContains(response, "id_my_sea_paid_draw_btn")
|
||
|
||
def test_gate_view_shows_paid_draw_btn_when_deposit_reserved(self):
|
||
from apps.gameboard.models import MySeaDraw
|
||
from apps.lyric.models import Token
|
||
free_tok = Token.objects.filter(user=self.user, token_type=Token.FREE).first()
|
||
MySeaDraw.objects.create(
|
||
user=self.user, spread="situation-action-outcome",
|
||
significator_id=self.target.id, hand=[],
|
||
deposit_token_id=free_tok.pk,
|
||
deposit_reserved_at=timezone.now(),
|
||
)
|
||
response = self.client.get(reverse("my_sea_gate"))
|
||
self.assertContains(response, "id_my_sea_paid_draw_btn")
|
||
self.assertContains(response, reverse("my_sea_refund_token"))
|
||
|
||
|
||
class MySeaInsertTokenViewTest(TestCase):
|
||
"""Sprint 6 iter 6a — POST `/gameboard/my-sea/insert` reserves the
|
||
user's next-priority token on their MySeaDraw row (creates the row
|
||
if missing). Idempotent w.r.t. an already-reserved deposit."""
|
||
|
||
def setUp(self):
|
||
from apps.epic.models import personal_sig_cards
|
||
self.user = User.objects.create(email="insert@test.io")
|
||
# Wipe auto-tokens from User post_save signal (COIN + FREE).
|
||
self.user.tokens.all().delete()
|
||
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.free_tok = Token.objects.create(
|
||
user=self.user, token_type=Token.FREE,
|
||
expires_at=timezone.now() + timedelta(days=30),
|
||
)
|
||
self.url = reverse("my_sea_insert_token")
|
||
|
||
def test_insert_get_returns_405(self):
|
||
self.assertEqual(self.client.get(self.url).status_code, 405)
|
||
|
||
def test_insert_creates_row_and_reserves_token(self):
|
||
from apps.gameboard.models import MySeaDraw
|
||
self.client.post(self.url)
|
||
draw = MySeaDraw.objects.get(user=self.user)
|
||
self.assertEqual(draw.deposit_token_id, self.free_tok.pk)
|
||
self.assertIsNotNone(draw.deposit_reserved_at)
|
||
|
||
def test_insert_uses_existing_row_when_one_exists(self):
|
||
from apps.gameboard.models import MySeaDraw
|
||
draw = MySeaDraw.objects.create(
|
||
user=self.user, spread="situation-action-outcome",
|
||
significator_id=self.target.id, hand=[],
|
||
)
|
||
self.client.post(self.url)
|
||
draw.refresh_from_db()
|
||
self.assertEqual(draw.deposit_token_id, self.free_tok.pk)
|
||
self.assertEqual(MySeaDraw.objects.filter(user=self.user).count(), 1)
|
||
|
||
def test_insert_idempotent_when_deposit_already_reserved(self):
|
||
from apps.gameboard.models import MySeaDraw
|
||
from apps.lyric.models import Token
|
||
other_tok = Token.objects.create(
|
||
user=self.user, token_type=Token.FREE,
|
||
expires_at=timezone.now() + timedelta(days=30),
|
||
)
|
||
draw = MySeaDraw.objects.create(
|
||
user=self.user, spread="situation-action-outcome",
|
||
significator_id=self.target.id, hand=[],
|
||
deposit_token_id=other_tok.pk,
|
||
deposit_reserved_at=timezone.now(),
|
||
)
|
||
self.client.post(self.url)
|
||
draw.refresh_from_db()
|
||
# Still pointed at the original token; no double-reserve.
|
||
self.assertEqual(draw.deposit_token_id, other_tok.pk)
|
||
|
||
|
||
class MySeaRefundTokenViewTest(TestCase):
|
||
"""Sprint 6 iter 6a — POST `/gameboard/my-sea/refund` clears the
|
||
deposit fields. Token wasn't actually consumed at INSERT (refund-
|
||
aware design), so no inventory side effects."""
|
||
|
||
def setUp(self):
|
||
from apps.epic.models import personal_sig_cards
|
||
from datetime import timedelta
|
||
from apps.gameboard.models import MySeaDraw
|
||
from apps.lyric.models import Token
|
||
self.user = User.objects.create(email="refund@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.tok = Token.objects.create(
|
||
user=self.user, token_type=Token.FREE,
|
||
expires_at=timezone.now() + timedelta(days=30),
|
||
)
|
||
self.draw = MySeaDraw.objects.create(
|
||
user=self.user, spread="situation-action-outcome",
|
||
significator_id=self.target.id, hand=[],
|
||
deposit_token_id=self.tok.pk,
|
||
deposit_reserved_at=timezone.now(),
|
||
)
|
||
self.url = reverse("my_sea_refund_token")
|
||
|
||
def test_refund_clears_deposit_fields(self):
|
||
self.client.post(self.url)
|
||
self.draw.refresh_from_db()
|
||
self.assertIsNone(self.draw.deposit_token_id)
|
||
self.assertIsNone(self.draw.deposit_reserved_at)
|
||
|
||
def test_refund_does_not_consume_token(self):
|
||
from apps.lyric.models import Token
|
||
self.client.post(self.url)
|
||
self.assertTrue(Token.objects.filter(pk=self.tok.pk).exists())
|
||
|
||
def test_refund_idempotent_when_no_deposit(self):
|
||
from apps.gameboard.models import MySeaDraw
|
||
MySeaDraw.objects.all().delete()
|
||
response = self.client.post(self.url)
|
||
self.assertIn(response.status_code, (200, 204, 302))
|
||
|
||
|
||
class MySeaPaidDrawViewTest(TestCase):
|
||
"""Sprint 6 iter 6a — POST `/gameboard/my-sea/paid-draw` commits the
|
||
deposited token + resets the row for a fresh 24h quota cycle. Per-
|
||
token-type debit rules apply (FREE/TITHE consumed, COIN cooldown,
|
||
PASS no-op, CARTE not reachable via `_select_my_sea_token`)."""
|
||
|
||
def setUp(self):
|
||
from apps.epic.models import personal_sig_cards
|
||
from datetime import timedelta
|
||
from apps.gameboard.models import MySeaDraw
|
||
from apps.lyric.models import Token
|
||
self.user = User.objects.create(email="paid@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.free_tok = Token.objects.create(
|
||
user=self.user, token_type=Token.FREE,
|
||
expires_at=timezone.now() + timedelta(days=30),
|
||
)
|
||
self.draw = MySeaDraw.objects.create(
|
||
user=self.user, spread="situation-action-outcome",
|
||
significator_id=self.target.id, hand=[],
|
||
deposit_token_id=self.free_tok.pk,
|
||
deposit_reserved_at=timezone.now(),
|
||
)
|
||
self.url = reverse("my_sea_paid_draw")
|
||
|
||
def test_paid_draw_consumes_free_token(self):
|
||
from apps.lyric.models import Token
|
||
self.client.post(self.url)
|
||
self.assertFalse(Token.objects.filter(pk=self.free_tok.pk).exists())
|
||
|
||
def test_paid_draw_preserves_row_and_sets_paid_through_at(self):
|
||
# User-spec 2026-05-23 (replaces the 2026-05-20 "delete row"
|
||
# spec): PAID DRAW preserves the row + sets `paid_through_at`
|
||
# so the landing PAID DRAW button stays visible across navigation
|
||
# cycles. Without this, the user who pays but doesn't immediately
|
||
# draw cards sees the button revert to FREE DRAW on next page
|
||
# load (the reported regression). `deposit_token_id` /
|
||
# `deposit_reserved_at` clear (token spent, no longer reserved);
|
||
# `hand` clears (fresh start per user-confirmed semantics).
|
||
from apps.gameboard.models import MySeaDraw
|
||
before = timezone.now()
|
||
self.client.post(self.url)
|
||
self.draw.refresh_from_db()
|
||
self.assertTrue(MySeaDraw.objects.filter(pk=self.draw.pk).exists(),
|
||
"PAID DRAW must preserve the row (was previously deleted)")
|
||
self.assertIsNone(self.draw.deposit_token_id)
|
||
self.assertIsNone(self.draw.deposit_reserved_at)
|
||
self.assertIsNotNone(self.draw.paid_through_at,
|
||
"PAID DRAW must stamp paid_through_at on commit")
|
||
self.assertGreaterEqual(self.draw.paid_through_at, before)
|
||
self.assertEqual(self.draw.hand, [],
|
||
"PAID DRAW must clear the hand (fresh paid session)")
|
||
|
||
def test_paid_draw_redirects_to_my_sea_with_phase_picker(self):
|
||
# User-spec 2026-05-20: drop the user directly into the picker
|
||
# after PAID DRAW (no intermediate FREE-DRAW click). Encoded via
|
||
# `?phase=picker` query param so the my_sea view can short-
|
||
# circuit `show_picker` even when active_draw is now None.
|
||
response = self.client.post(self.url)
|
||
self.assertEqual(response.status_code, 302)
|
||
self.assertIn("phase=picker", response["Location"])
|
||
|
||
def test_paid_draw_with_coin_sets_24h_cooldown_and_unequips(self):
|
||
from datetime import timedelta
|
||
from apps.lyric.models import Token
|
||
coin = Token.objects.create(user=self.user, token_type=Token.COIN)
|
||
self.user.equipped_trinket = coin
|
||
self.user.save(update_fields=["equipped_trinket"])
|
||
self.draw.deposit_token_id = coin.pk
|
||
self.draw.save(update_fields=["deposit_token_id"])
|
||
before = timezone.now()
|
||
self.client.post(self.url)
|
||
coin.refresh_from_db()
|
||
self.assertTrue(coin.next_ready_at >= before + timedelta(hours=23, minutes=58))
|
||
self.assertTrue(coin.next_ready_at <= before + timedelta(hours=24, minutes=2))
|
||
self.user.refresh_from_db()
|
||
self.assertIsNone(self.user.equipped_trinket_id)
|
||
|
||
def test_paid_draw_with_pass_does_not_consume(self):
|
||
from apps.lyric.models import Token
|
||
self.user.is_staff = True
|
||
self.user.save(update_fields=["is_staff"])
|
||
pass_tok = Token.objects.create(user=self.user, token_type=Token.PASS)
|
||
self.draw.deposit_token_id = pass_tok.pk
|
||
self.draw.save(update_fields=["deposit_token_id"])
|
||
self.client.post(self.url)
|
||
self.assertTrue(Token.objects.filter(pk=pass_tok.pk).exists())
|
||
|
||
def test_paid_draw_no_deposit_redirects_to_my_sea(self):
|
||
self.draw.deposit_token_id = None
|
||
self.draw.save(update_fields=["deposit_token_id"])
|
||
response = self.client.post(self.url)
|
||
self.assertEqual(response.status_code, 302)
|
||
|
||
|
||
class MySeaCooldownAnchoredToFreeDrawTest(TestCase):
|
||
"""User-spec 2026-05-23: the 24h free-draw cooldown is anchored to the
|
||
user's last TOKENLESS first-card-draw (`User.last_free_draw_at`), NOT
|
||
to any subsequent paid draws. A paid draw in the middle of the cycle
|
||
must NOT push the cooldown forward — the next FREE DRAW unlocks at
|
||
free-draw + 24h regardless of any interim paid activity.
|
||
|
||
Also pins the "sticky PAID DRAW button" UX: after PAID DRAW commits,
|
||
the row carries a `paid_through_at` credit until the first card of
|
||
the paid session lands. During that window, any navigation back to
|
||
/gameboard/my-sea/ keeps the landing button labelled PAID DRAW (it
|
||
used to revert to FREE DRAW because the row was deleted at commit
|
||
time — the user-reported bug)."""
|
||
|
||
def setUp(self):
|
||
from apps.epic.models import personal_sig_cards
|
||
from apps.lyric.models import Token
|
||
self.user = User.objects.create(email="anchor@test.io")
|
||
self.user.tokens.all().delete()
|
||
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"])
|
||
# FREE token for the paid-draw step.
|
||
self.free_tok = Token.objects.create(
|
||
user=self.user, token_type=Token.FREE,
|
||
expires_at=timezone.now() + timedelta(days=30),
|
||
)
|
||
|
||
def _seed_used_free_draw(self, when=None):
|
||
"""Simulate a completed FREE DRAW + DEL: row exists w. hand=[],
|
||
no deposit, `User.last_free_draw_at` anchored at `when`."""
|
||
from apps.gameboard.models import MySeaDraw
|
||
when = when or timezone.now()
|
||
self.user.last_free_draw_at = when
|
||
self.user.save(update_fields=["last_free_draw_at"])
|
||
return MySeaDraw.objects.create(
|
||
user=self.user, spread="situation-action-outcome",
|
||
significator_id=self.target.id, hand=[],
|
||
created_at=when,
|
||
)
|
||
|
||
def test_paid_draw_does_not_reset_user_last_free_draw_at(self):
|
||
original_anchor = timezone.now() - timedelta(hours=6)
|
||
draw = self._seed_used_free_draw(when=original_anchor)
|
||
# Deposit + commit a token via the PAID DRAW endpoint.
|
||
draw.deposit_token_id = self.free_tok.pk
|
||
draw.deposit_reserved_at = timezone.now()
|
||
draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
|
||
self.client.post(reverse("my_sea_paid_draw"))
|
||
self.user.refresh_from_db()
|
||
# User.last_free_draw_at stays at the original anchor
|
||
# (within 1s tolerance for timestamp wobble).
|
||
delta = abs(
|
||
(self.user.last_free_draw_at - original_anchor).total_seconds()
|
||
)
|
||
self.assertLess(delta, 1.0,
|
||
"PAID DRAW must NOT touch User.last_free_draw_at — the "
|
||
"cooldown stays anchored to the original FREE DRAW per "
|
||
"user-spec 2026-05-23")
|
||
|
||
def test_brief_next_free_draw_at_uses_user_anchor_not_paid_row(self):
|
||
# The view passes `next_free_draw_at` to the template as ISO
|
||
# — the Brief script in my_sea.html surfaces this directly.
|
||
# Anchor: user's last_free_draw_at + 24h, NOT row.created_at
|
||
# + 24h (which after PAID DRAW would point 24h past the paid
|
||
# commit, not 24h past the free draw).
|
||
original_anchor = timezone.now() - timedelta(hours=6)
|
||
self._seed_used_free_draw(when=original_anchor)
|
||
# The active row's `created_at` matches the seed time (free
|
||
# draw moment). Now simulate a "row created LATER than anchor"
|
||
# state by bumping created_at forward — this is what would
|
||
# happen if the row were re-created at PAID DRAW time under
|
||
# the old buggy delete-on-commit semantics.
|
||
from apps.gameboard.models import MySeaDraw
|
||
row = MySeaDraw.objects.get(user=self.user)
|
||
row.created_at = timezone.now() # "PAID DRAW just created me"
|
||
row.save(update_fields=["created_at"])
|
||
response = self.client.get(reverse("my_sea"))
|
||
# The user's next_free_draw_at = anchor + 24h, NOT row.created_at
|
||
# + 24h. Differs by ~6h; check that the rendered ISO matches the
|
||
# user-level anchor (truncated to date+hour for stability).
|
||
expected_user_iso = (
|
||
original_anchor + timedelta(hours=24)
|
||
).isoformat()[:13] # "YYYY-MM-DDTHH" — date + hour
|
||
self.assertIn(expected_user_iso, response.content.decode())
|
||
|
||
def test_paid_draw_commit_makes_landing_show_paid_draw_btn(self):
|
||
# End-to-end of the user-reported bug: deposit → PAID DRAW commit
|
||
# → navigate to /gameboard/my-sea/ → landing must show PAID DRAW
|
||
# (NOT FREE DRAW, NOT GATE VIEW). Pre-fix: row was deleted at
|
||
# commit time + landing fell through to FREE DRAW.
|
||
from apps.gameboard.models import MySeaDraw
|
||
draw = self._seed_used_free_draw()
|
||
draw.deposit_token_id = self.free_tok.pk
|
||
draw.deposit_reserved_at = timezone.now()
|
||
draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
|
||
# Commit the paid draw.
|
||
self.client.post(reverse("my_sea_paid_draw"))
|
||
# Simulate user navigating away + back: re-render my_sea.
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertContains(response, 'id="id_my_sea_paid_draw_btn"',
|
||
msg_prefix="post-PAID-DRAW navigation must keep PAID DRAW btn")
|
||
self.assertNotContains(response, 'id="id_draw_sea_btn"',
|
||
msg_prefix="FREE DRAW btn must NOT show after PAID DRAW commit")
|
||
self.assertNotContains(response, 'id="id_my_sea_gate_view_btn"',
|
||
msg_prefix="GATE VIEW btn must NOT show while paid-through is set")
|
||
|
||
def test_paid_draw_btn_post_with_paid_through_redirects_to_picker(self):
|
||
# After commit, the PAID DRAW button on the landing should
|
||
# route the user back to the picker (via ?phase=picker) without
|
||
# consuming another token.
|
||
from apps.gameboard.models import MySeaDraw
|
||
draw = self._seed_used_free_draw()
|
||
draw.paid_through_at = timezone.now()
|
||
draw.save(update_fields=["paid_through_at"])
|
||
free_count_before = self.user.tokens.filter(
|
||
token_type=self.free_tok.token_type
|
||
).count()
|
||
response = self.client.post(reverse("my_sea_paid_draw"))
|
||
self.assertEqual(response.status_code, 302)
|
||
self.assertIn("phase=picker", response["Location"])
|
||
# No token consumed — the paid-through credit covers this.
|
||
self.assertEqual(
|
||
self.user.tokens.filter(
|
||
token_type=self.free_tok.token_type
|
||
).count(),
|
||
free_count_before,
|
||
)
|
||
|
||
def test_first_card_after_paid_draw_consumes_paid_through_credit(self):
|
||
# User-spec 2026-05-23 follow-up: paid-through is one-shot. Once
|
||
# the user draws their first card of the paid session, the
|
||
# credit is consumed → next redraw needs a fresh deposit.
|
||
import json
|
||
from apps.gameboard.models import MySeaDraw
|
||
from apps.epic.models import TarotCard
|
||
draw = self._seed_used_free_draw()
|
||
draw.paid_through_at = timezone.now()
|
||
draw.save(update_fields=["paid_through_at"])
|
||
card = TarotCard.objects.exclude(id=self.target.id).first()
|
||
self.client.post(
|
||
reverse("my_sea_lock"),
|
||
data=json.dumps({
|
||
"spread": "situation-action-outcome",
|
||
"hand": [{
|
||
"position": "lay", "card_id": card.id,
|
||
"reversed": False, "polarity": "gravity",
|
||
}],
|
||
}),
|
||
content_type="application/json",
|
||
)
|
||
draw.refresh_from_db()
|
||
self.assertIsNone(draw.paid_through_at,
|
||
"first card of paid session consumes the paid-through credit")
|
||
|
||
|
||
class UserFreeDrawCooldownPropertyTest(TestCase):
|
||
"""`User.free_draw_cooldown_active` + `User.next_free_draw_at`
|
||
helpers. The cooldown is sticky from `last_free_draw_at` (set on the
|
||
user's last tokenless first-card-draw) for FREE_DRAW_COOLDOWN_HOURS."""
|
||
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="cooldown@test.io")
|
||
|
||
def test_no_last_free_draw_at_returns_false(self):
|
||
self.assertIsNone(self.user.last_free_draw_at)
|
||
self.assertFalse(self.user.free_draw_cooldown_active)
|
||
self.assertIsNone(self.user.next_free_draw_at)
|
||
|
||
def test_recent_last_free_draw_at_returns_true(self):
|
||
self.user.last_free_draw_at = timezone.now() - timedelta(hours=6)
|
||
self.user.save()
|
||
self.assertTrue(self.user.free_draw_cooldown_active)
|
||
|
||
def test_old_last_free_draw_at_returns_false(self):
|
||
self.user.last_free_draw_at = timezone.now() - timedelta(hours=25)
|
||
self.user.save()
|
||
self.assertFalse(self.user.free_draw_cooldown_active)
|
||
|
||
def test_next_free_draw_at_is_last_plus_24h(self):
|
||
anchor = timezone.now() - timedelta(hours=6)
|
||
self.user.last_free_draw_at = anchor
|
||
self.user.save()
|
||
self.assertEqual(
|
||
self.user.next_free_draw_at,
|
||
anchor + timedelta(hours=24),
|
||
)
|
||
|
||
|
||
class MySeaPhasePickerQueryParamTest(TestCase):
|
||
"""`?phase=picker` query param forces picker phase when the user is
|
||
in a paid cycle (post-PAID-DRAW commit, hand still empty). Updated
|
||
2026-05-23 — previously the param worked w. no active row (the old
|
||
"delete row at PAID DRAW" semantics). Under the new "preserve row +
|
||
set paid_through_at" semantics, the row is present + paid_through_at
|
||
is set; the picker shows via the param + the paid-through state."""
|
||
|
||
def setUp(self):
|
||
from apps.epic.models import personal_sig_cards
|
||
self.user = User.objects.create(email="phase@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_no_param_lands_on_free_draw(self):
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertContains(response, 'data-phase="landing"')
|
||
self.assertContains(response, 'id="id_draw_sea_btn"')
|
||
|
||
def test_phase_picker_param_forces_picker_when_paid_through(self):
|
||
# Simulate the just-after-PAID-DRAW state: row exists w. hand=[]
|
||
# + paid_through_at set. The ?phase=picker param drops the user
|
||
# into the picker rather than the landing.
|
||
from apps.gameboard.models import MySeaDraw
|
||
MySeaDraw.objects.create(
|
||
user=self.user, spread="situation-action-outcome",
|
||
significator_id=self.target.id, hand=[],
|
||
paid_through_at=timezone.now(),
|
||
)
|
||
response = self.client.get(reverse("my_sea") + "?phase=picker")
|
||
self.assertContains(response, 'data-phase="picker"')
|
||
self.assertNotContains(response, 'id="id_sea_overlay"' + ' style="display:none"')
|
||
|
||
def test_phase_picker_param_ignored_when_active_draw_with_empty_hand(self):
|
||
# Post-DEL state: active row w. empty hand + NO paid-through
|
||
# credit → the user still needs to gatekeeper. Landing wins;
|
||
# GATE VIEW button shown.
|
||
from apps.gameboard.models import MySeaDraw
|
||
MySeaDraw.objects.create(
|
||
user=self.user, spread="situation-action-outcome",
|
||
significator_id=self.target.id, hand=[],
|
||
)
|
||
response = self.client.get(reverse("my_sea") + "?phase=picker")
|
||
self.assertContains(response, 'data-phase="landing"')
|
||
self.assertContains(response, 'id="id_my_sea_gate_view_btn"')
|
||
|
||
def test_paid_through_with_empty_hand_renders_paid_draw_btn_not_free(self):
|
||
"""User-reported bug 2026-05-23 — after PAID DRAW commit, if the
|
||
user navigates away without drawing, the landing button must
|
||
stay as PAID DRAW (not revert to FREE DRAW). Preserved-row +
|
||
`paid_through_at` is the regression-pinning state."""
|
||
from apps.gameboard.models import MySeaDraw
|
||
MySeaDraw.objects.create(
|
||
user=self.user, spread="situation-action-outcome",
|
||
significator_id=self.target.id, hand=[],
|
||
paid_through_at=timezone.now(),
|
||
)
|
||
response = self.client.get(reverse("my_sea"))
|
||
self.assertContains(response, 'data-phase="landing"')
|
||
self.assertContains(response, 'id="id_my_sea_paid_draw_btn"')
|
||
self.assertNotContains(response, 'id="id_draw_sea_btn"')
|
||
self.assertNotContains(response, 'id="id_my_sea_gate_view_btn"')
|
||
|
||
|
||
class SelectMySeaTokenTest(TestCase):
|
||
"""Sprint 6 iter 6a — `_select_my_sea_token` priority chain w. CARTE
|
||
excluded + COIN cooldown-respecting."""
|
||
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="selecttok@test.io")
|
||
# New-user post_save signal auto-creates COIN + FREE tokens
|
||
# (`apps.lyric.models`). Wipe them so each test only sees the
|
||
# tokens it explicitly seeds. Refresh so equipped_trinket_id
|
||
# picks up the cascade SET_NULL (otherwise the Python object
|
||
# stays stale + select_token's defensive fresh-DB-query finds
|
||
# an SQLite-reused-pk Token that "happens" to match).
|
||
self.user.tokens.all().delete()
|
||
self.user.refresh_from_db()
|
||
|
||
def test_carte_is_excluded(self):
|
||
from apps.gameboard.models import _select_my_sea_token
|
||
Token.objects.create(user=self.user, token_type=Token.CARTE)
|
||
self.assertIsNone(_select_my_sea_token(self.user))
|
||
|
||
def test_cooldown_coin_is_excluded(self):
|
||
from apps.gameboard.models import _select_my_sea_token
|
||
Token.objects.create(
|
||
user=self.user, token_type=Token.COIN,
|
||
next_ready_at=timezone.now() + timedelta(hours=12),
|
||
)
|
||
self.assertIsNone(_select_my_sea_token(self.user))
|
||
|
||
def test_pass_wins_priority_for_staff(self):
|
||
"""PASS must be EQUIPPED to be picked — DON-ing it is the user's
|
||
opt-in to trinket use. Owned-but-DOFFed PASS stays invisible to
|
||
the picker (parity w. all other trinkets under the equip-gated
|
||
semantics — see [[feedback-equip-slot-gates-trinket-use]])."""
|
||
from apps.lyric.models import Token
|
||
from apps.gameboard.models import _select_my_sea_token
|
||
self.user.is_staff = True
|
||
self.user.save(update_fields=["is_staff"])
|
||
pass_tok = Token.objects.create(user=self.user, token_type=Token.PASS)
|
||
Token.objects.create(user=self.user, token_type=Token.COIN)
|
||
self.user.equipped_trinket = pass_tok
|
||
self.user.save(update_fields=["equipped_trinket"])
|
||
self.assertEqual(_select_my_sea_token(self.user), pass_tok)
|
||
|
||
|
||
class SelectMySeaTokenEquipGatedTest(TestCase):
|
||
"""My-sea variant of `SelectTokenEquipGatedTest`. Same equip-gated
|
||
semantics: trinkets must be DON-ed to fire; CARTE never auto-picked;
|
||
fall back to FREE → TITHE → None. PLUS my-sea's cooldown check on
|
||
COIN (24h after debit_my_sea_token consumes one)."""
|
||
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="myseaequip@test.io")
|
||
self.user.tokens.all().delete()
|
||
self.user.refresh_from_db()
|
||
|
||
def test_skips_unequipped_coin_and_returns_free(self):
|
||
from apps.lyric.models import Token
|
||
from apps.gameboard.models import _select_my_sea_token
|
||
Token.objects.create(user=self.user, token_type=Token.COIN)
|
||
free = Token.objects.create(
|
||
user=self.user, token_type=Token.FREE,
|
||
expires_at=timezone.now() + timedelta(days=7),
|
||
)
|
||
self.assertEqual(_select_my_sea_token(self.user).pk, free.pk)
|
||
|
||
def test_skips_unequipped_band_and_returns_tithe(self):
|
||
from apps.lyric.models import Token
|
||
from apps.gameboard.models import _select_my_sea_token
|
||
Token.objects.create(user=self.user, token_type=Token.BAND)
|
||
tithe = Token.objects.create(user=self.user, token_type=Token.TITHE)
|
||
self.assertEqual(_select_my_sea_token(self.user).pk, tithe.pk)
|
||
|
||
def test_returns_none_when_no_equip_and_no_consumables(self):
|
||
from apps.lyric.models import Token
|
||
from apps.gameboard.models import _select_my_sea_token
|
||
Token.objects.create(user=self.user, token_type=Token.COIN)
|
||
self.assertIsNone(_select_my_sea_token(self.user))
|
||
|
||
def test_equipped_band_returns_band(self):
|
||
from apps.lyric.models import Token
|
||
from apps.gameboard.models import _select_my_sea_token
|
||
band = Token.objects.create(user=self.user, token_type=Token.BAND)
|
||
self.user.equipped_trinket = band
|
||
self.user.save(update_fields=["equipped_trinket"])
|
||
self.assertEqual(_select_my_sea_token(self.user).pk, band.pk)
|
||
|
||
def test_equipped_cooldown_coin_falls_through_to_free(self):
|
||
"""Equipped COIN on 24h cooldown shouldn't occur (debit_my_sea_
|
||
token auto-unequips), but defensive: if it does, the picker
|
||
should respect the cooldown + fall through, not return a stale
|
||
COIN that debit_my_sea_token would refuse."""
|
||
from apps.lyric.models import Token
|
||
from apps.gameboard.models import _select_my_sea_token
|
||
coin = Token.objects.create(
|
||
user=self.user, token_type=Token.COIN,
|
||
next_ready_at=timezone.now() + timedelta(hours=12),
|
||
)
|
||
free = Token.objects.create(
|
||
user=self.user, token_type=Token.FREE,
|
||
expires_at=timezone.now() + timedelta(days=7),
|
||
)
|
||
self.user.equipped_trinket = coin
|
||
self.user.save(update_fields=["equipped_trinket"])
|
||
self.assertEqual(_select_my_sea_token(self.user).pk, free.pk)
|
||
|
||
def test_carte_equipped_falls_through_to_free(self):
|
||
"""CARTE is fully excluded from my-sea (debit_my_sea_token raises
|
||
ValueError for CARTE). Even equipped, it's never auto-picked here."""
|
||
from apps.lyric.models import Token
|
||
from apps.gameboard.models import _select_my_sea_token
|
||
carte = Token.objects.create(user=self.user, token_type=Token.CARTE)
|
||
free = Token.objects.create(
|
||
user=self.user, token_type=Token.FREE,
|
||
expires_at=timezone.now() + timedelta(days=7),
|
||
)
|
||
self.user.equipped_trinket = carte
|
||
self.user.save(update_fields=["equipped_trinket"])
|
||
self.assertEqual(_select_my_sea_token(self.user).pk, free.pk)
|
||
|
||
|
||
class DebitMySeaTokenTest(TestCase):
|
||
"""Sprint 6 iter 6a — `debit_my_sea_token` per-type semantics."""
|
||
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="debittok@test.io")
|
||
|
||
def test_carte_raises_value_error(self):
|
||
from apps.lyric.models import Token
|
||
from apps.gameboard.models import debit_my_sea_token
|
||
carte = Token.objects.create(user=self.user, token_type=Token.CARTE)
|
||
with self.assertRaises(ValueError):
|
||
debit_my_sea_token(self.user, carte)
|
||
|
||
def test_free_token_is_consumed(self):
|
||
from datetime import timedelta
|
||
from apps.lyric.models import Token
|
||
from apps.gameboard.models import debit_my_sea_token
|
||
free = Token.objects.create(
|
||
user=self.user, token_type=Token.FREE,
|
||
expires_at=timezone.now() + timedelta(days=30),
|
||
)
|
||
debit_my_sea_token(self.user, free)
|
||
self.assertFalse(Token.objects.filter(pk=free.pk).exists())
|
||
|
||
def test_tithe_token_is_consumed(self):
|
||
from apps.lyric.models import Token
|
||
from apps.gameboard.models import debit_my_sea_token
|
||
tithe = Token.objects.create(user=self.user, token_type=Token.TITHE)
|
||
debit_my_sea_token(self.user, tithe)
|
||
self.assertFalse(Token.objects.filter(pk=tithe.pk).exists())
|
||
|
||
def test_pass_token_is_not_consumed(self):
|
||
from apps.lyric.models import Token
|
||
from apps.gameboard.models import debit_my_sea_token
|
||
self.user.is_staff = True
|
||
self.user.save(update_fields=["is_staff"])
|
||
pass_tok = Token.objects.create(user=self.user, token_type=Token.PASS)
|
||
debit_my_sea_token(self.user, pass_tok)
|
||
self.assertTrue(Token.objects.filter(pk=pass_tok.pk).exists())
|