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_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_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'