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 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 `
` lands AFTER the name `
` 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'