Files
python-tdd/src/apps/gameboard/tests/integrated/test_views.py
Disco DeDisco a9ad422b35 A.7.5 Game Kit carousel image-mode + universal stat-block top-left chip + EMANATION/REVERSAL --secUser convention — TDD. Mid-session 2026-05-25 PM (Sprint A.7.5 of [[project-image-based-deck-face-rendering]] — slotted between A.7 polish + tomorrow's A.8 room.html). Three threads bundled: (1) Game Kit _tarot_fan.html carousel modal gets the image-mode branch + per-card FLIP-to-back for non-polarized image-equipped decks (Minchiate today; brings the carousel into parity w. the other 5 image-mode surfaces shipped in A.3-A.7); (2) the A.3 Q3-spec top-left rank+suit chip lands across all 4 stat-block surfaces (my_sign main / _applet-my-sign / _sea_stage modal / new game_kit fan stage), retrofitting work that A.3 explicitly deferred per the "Lower-priority follow-ups" list in the project memory; (3) chip + EMANATION/REVERSAL label adopt --secUser as the new universal color convention so the title (--quaUser/--terUser per arcana) stays the focal text + the chip-and-label header recedes visually.
(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>
2026-05-25 14:25:41 -04:00

2763 lines
129 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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())