2026-05-19 01:38:55 -04:00
|
|
|
"""FTs for the My Sea standalone page sign-gate.
|
|
|
|
|
|
|
|
|
|
Sprint 4b of [[project-my-sea-roadmap]]. The /gameboard/my-sea/ page is
|
|
|
|
|
gated behind sig selection — when `user.significator` is None, render a
|
2026-05-19 15:15:37 -04:00
|
|
|
Look!-formatted Brief-style line w. FYI (→ /billboard/my-sign/) + NVM
|
2026-05-19 01:38:55 -04:00
|
|
|
(→ /gameboard/) instead of the draw UX. The My Sea applet on /gameboard/
|
|
|
|
|
mirrors the gate hint in its empty-state slot.
|
|
|
|
|
"""
|
|
|
|
|
from selenium.webdriver.common.by import By
|
|
|
|
|
|
|
|
|
|
from .base import FunctionalTest
|
2026-05-19 14:22:49 -04:00
|
|
|
from .sig_page import _assign_sig, _seed_earthman_sig_pile
|
2026-05-19 01:38:55 -04:00
|
|
|
from apps.applets.models import Applet
|
2026-05-19 14:22:49 -04:00
|
|
|
from apps.epic.models import personal_sig_cards
|
2026-05-19 01:38:55 -04:00
|
|
|
from apps.lyric.models import User
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seed_gameboard_applets():
|
|
|
|
|
"""My Sea + the rest of the gameboard applets so /gameboard/ renders
|
2026-05-19 11:05:39 -04:00
|
|
|
without missing-applet errors during the applet-side assertions.
|
|
|
|
|
Mirrors the migration seed (0003 + 0008) — every slug must have a
|
|
|
|
|
matching _applet-<slug>.html partial under apps/gameboard/_partials/."""
|
2026-05-19 01:38:55 -04:00
|
|
|
for slug, name, cols, rows, ctx in [
|
|
|
|
|
("my-sea", "My Sea", 12, 4, "gameboard"),
|
2026-05-19 11:05:39 -04:00
|
|
|
("game-kit", "Game Kit", 4, 3, "gameboard"),
|
|
|
|
|
("new-game", "New Game", 4, 3, "gameboard"),
|
2026-05-19 01:38:55 -04:00
|
|
|
("my-games", "My Games", 4, 4, "gameboard"),
|
|
|
|
|
]:
|
|
|
|
|
Applet.objects.get_or_create(
|
|
|
|
|
slug=slug,
|
|
|
|
|
defaults={"name": name, "context": ctx,
|
|
|
|
|
"default_visible": True, "grid_cols": cols, "grid_rows": rows},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MySeaSignGateTest(FunctionalTest):
|
|
|
|
|
"""Sign-gate UX on the standalone /gameboard/my-sea/ page + the
|
|
|
|
|
/gameboard/ My Sea applet. User without a saved sig sees a Look!-
|
2026-05-19 15:15:37 -04:00
|
|
|
formatted nudge w. FYI to the picker + NVM to the gameboard."""
|
2026-05-19 01:38:55 -04:00
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
super().setUp()
|
2026-05-19 11:05:39 -04:00
|
|
|
_seed_earthman_sig_pile()
|
2026-05-19 01:38:55 -04:00
|
|
|
_seed_gameboard_applets()
|
|
|
|
|
self.email = "sea@test.io"
|
|
|
|
|
self.gamer = User.objects.create(email=self.email)
|
|
|
|
|
sig_pile = personal_sig_cards(self.gamer)
|
|
|
|
|
self.target_card = sig_pile[0] if sig_pile else None
|
|
|
|
|
self.assertIsNotNone(
|
|
|
|
|
self.target_card,
|
|
|
|
|
"personal_sig_cards(user) returned no cards — check Earthman seed",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── Test 1 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_no_sig_renders_lookline_gate_on_standalone_page(self):
|
|
|
|
|
"""User without significator → /gameboard/my-sea/ shows the Look!-
|
2026-05-19 15:15:37 -04:00
|
|
|
formatted Brief-style line w. the gate copy + FYI + NVM buttons."""
|
2026-05-19 01:38:55 -04:00
|
|
|
self.create_pre_authenticated_session(self.email)
|
|
|
|
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
|
|
|
|
gate = self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".my-sea-sign-gate"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
text = gate.text
|
|
|
|
|
self.assertIn("Look!", text)
|
|
|
|
|
self.assertIn("pick your sign", text.lower())
|
|
|
|
|
self.assertIn("drawing the Sea", text)
|
2026-05-19 15:15:37 -04:00
|
|
|
# FYI + NVM action buttons (class .my-sea-sign-gate__back retained
|
|
|
|
|
# post-relabel; the BACK→NVM swap was label-only).
|
2026-05-19 01:38:55 -04:00
|
|
|
fyi = gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__fyi")
|
|
|
|
|
self.assertTrue(fyi.is_displayed())
|
2026-05-19 15:15:37 -04:00
|
|
|
nvm = gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__back")
|
|
|
|
|
self.assertTrue(nvm.is_displayed())
|
2026-05-19 01:38:55 -04:00
|
|
|
|
|
|
|
|
# ── Test 2 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_gate_fyi_links_to_my_sign_picker(self):
|
|
|
|
|
"""FYI button is an `<a href>` pointing at /billboard/my-sign/."""
|
|
|
|
|
self.create_pre_authenticated_session(self.email)
|
|
|
|
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
|
|
|
|
fyi = self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".my-sea-sign-gate__fyi"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
href = fyi.get_attribute("href") or ""
|
|
|
|
|
self.assertTrue(
|
|
|
|
|
href.endswith("/billboard/my-sign/"),
|
|
|
|
|
f"FYI should link to /billboard/my-sign/, got {href!r}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── Test 3 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_gate_back_links_to_gameboard(self):
|
2026-05-19 15:15:37 -04:00
|
|
|
"""NVM button is an `<a href>` pointing at /gameboard/. CSS class
|
|
|
|
|
`.my-sea-sign-gate__back` retained post BACK→NVM label swap."""
|
2026-05-19 01:38:55 -04:00
|
|
|
self.create_pre_authenticated_session(self.email)
|
|
|
|
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
2026-05-19 15:15:37 -04:00
|
|
|
nvm = self.wait_for(
|
2026-05-19 01:38:55 -04:00
|
|
|
lambda: self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".my-sea-sign-gate__back"
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-05-19 15:15:37 -04:00
|
|
|
href = nvm.get_attribute("href") or ""
|
2026-05-19 01:38:55 -04:00
|
|
|
self.assertTrue(
|
|
|
|
|
href.endswith("/gameboard/"),
|
2026-05-19 15:15:37 -04:00
|
|
|
f"NVM should link to /gameboard/, got {href!r}",
|
2026-05-19 01:38:55 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── Test 4 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_with_sig_skips_gate_and_renders_draw_shell(self):
|
|
|
|
|
"""User w. saved significator → no .my-sea-sign-gate on the page;
|
|
|
|
|
draw shell renders normally (Sprint 3 placeholder)."""
|
2026-05-19 14:22:49 -04:00
|
|
|
_assign_sig(self.gamer, self.target_card)
|
2026-05-19 01:38:55 -04:00
|
|
|
self.create_pre_authenticated_session(self.email)
|
|
|
|
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
|
|
|
|
self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".my-sea-page")
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
len(self.browser.find_elements(By.CSS_SELECTOR, ".my-sea-sign-gate")),
|
|
|
|
|
0,
|
|
|
|
|
"Gate should not render when user has a saved significator",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── Test 5 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_no_sig_applet_mirrors_gate_with_fyi_link(self):
|
|
|
|
|
"""On /gameboard/, the My Sea applet's empty state shows the same
|
|
|
|
|
Look!-formatted gate w. FYI link to /billboard/my-sign/ when the
|
|
|
|
|
user has no significator. Provides a consistent UX across surfaces."""
|
|
|
|
|
self.create_pre_authenticated_session(self.email)
|
|
|
|
|
self.browser.get(self.live_server_url + "/gameboard/")
|
|
|
|
|
applet_gate = self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR, "#id_applet_my_sea .my-sea-sign-gate"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("Look!", applet_gate.text)
|
|
|
|
|
fyi = applet_gate.find_element(By.CSS_SELECTOR, ".my-sea-sign-gate__fyi")
|
|
|
|
|
href = fyi.get_attribute("href") or ""
|
|
|
|
|
self.assertTrue(href.endswith("/billboard/my-sign/"))
|
|
|
|
|
|
|
|
|
|
# ── Test 6 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_with_sig_applet_renders_default_empty_state(self):
|
|
|
|
|
"""Applet w. saved sig → no gate, empty-state placeholder (until
|
|
|
|
|
Sprint 7 wires up the latest-draw rendering)."""
|
2026-05-19 14:22:49 -04:00
|
|
|
_assign_sig(self.gamer, self.target_card)
|
2026-05-19 01:38:55 -04:00
|
|
|
self.create_pre_authenticated_session(self.email)
|
|
|
|
|
self.browser.get(self.live_server_url + "/gameboard/")
|
|
|
|
|
self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_applet_my_sea")
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
len(self.browser.find_elements(
|
|
|
|
|
By.CSS_SELECTOR, "#id_applet_my_sea .my-sea-sign-gate"
|
|
|
|
|
)),
|
|
|
|
|
0,
|
|
|
|
|
)
|
2026-05-19 15:15:37 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class MySeaDrawSeaLandingTest(FunctionalTest):
|
2026-05-19 15:48:07 -04:00
|
|
|
"""Sprint 5 iter 1 — FREE DRAW landing on /gameboard/my-sea/ for a
|
2026-05-19 15:15:37 -04:00
|
|
|
user w. a saved sig (past the [[sprint-my-sea-sign-gate-may19]] gate).
|
|
|
|
|
|
|
|
|
|
Landing renders a DRY table hex (parameterized from the room) w. 6
|
|
|
|
|
chair seats labeled 1C-6C (placeholders for the eventual friend-
|
|
|
|
|
invite feature per [[project-my-sea-roadmap]] architectural anchor
|
2026-05-19 15:48:07 -04:00
|
|
|
"Six chairs retained even in solo") + a central FREE DRAW `.btn-
|
|
|
|
|
primary` mirroring SCAN SIGN on /billboard/my-sign/. Each chair seat
|
|
|
|
|
renders w. a red `.fa-ban` status icon (empty slot).
|
2026-05-19 15:15:37 -04:00
|
|
|
|
2026-05-19 15:48:07 -04:00
|
|
|
Click flow: FREE DRAW → seat 1C transitions to `.seated` state
|
|
|
|
|
(chair `--terUser` + drop-shadow glow + `.fa-ban` swap to `.fa-
|
|
|
|
|
circle-check` green) → after a brief delay so the user sees the
|
|
|
|
|
animation, `data-phase` swaps to `picker` (picker content lands in
|
|
|
|
|
iter 2). The 'C' = "Chair" (user-locked vocabulary); no role
|
|
|
|
|
semantics in this solo flow.
|
|
|
|
|
|
|
|
|
|
"FREE DRAW" is the label for the 1/24h free quota draw — a future
|
|
|
|
|
sprint will conditionally swap the label to "DRAW SEA" once the
|
|
|
|
|
free has been used, w. the DRAW SEA btn calling the room
|
|
|
|
|
gatekeeper partial for token-deposit.
|
|
|
|
|
|
|
|
|
|
The same Brief "Default deck warning" copy from my-sign fires when
|
|
|
|
|
the user has no equipped deck."""
|
2026-05-19 15:15:37 -04:00
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
super().setUp()
|
|
|
|
|
_seed_earthman_sig_pile()
|
|
|
|
|
_seed_gameboard_applets()
|
|
|
|
|
self.email = "draw@test.io"
|
|
|
|
|
self.gamer = User.objects.create(email=self.email)
|
|
|
|
|
# Assign a sig so the page passes the Sprint 4b gate + lands on
|
|
|
|
|
# the new DRAW SEA UX rather than the Look!-line gate.
|
|
|
|
|
self.target_card = _assign_sig(self.gamer)
|
|
|
|
|
|
|
|
|
|
# ── Test 1 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-19 15:48:07 -04:00
|
|
|
def test_landing_renders_hex_with_free_draw_btn(self):
|
2026-05-19 15:15:37 -04:00
|
|
|
"""User w. sig → /gameboard/my-sea/ shows the DRY table hex (re-
|
2026-05-19 15:48:07 -04:00
|
|
|
used from my-sign / the room shell) w. a central FREE DRAW btn.
|
|
|
|
|
Element ID `id_draw_sea_btn` describes intent (the draw entry
|
|
|
|
|
point) — a future sprint will conditionally swap the label to
|
|
|
|
|
DRAW SEA once the daily free has been used."""
|
2026-05-19 15:15:37 -04:00
|
|
|
self.create_pre_authenticated_session(self.email)
|
|
|
|
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
|
|
|
|
# data-phase=landing on the page wrapper
|
|
|
|
|
page = self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".my-sea-page[data-phase='landing']")
|
|
|
|
|
)
|
|
|
|
|
# Hex shell present
|
|
|
|
|
page.find_element(By.CSS_SELECTOR, ".room-shell .table-hex")
|
2026-05-19 15:48:07 -04:00
|
|
|
# FREE DRAW btn in hex center
|
2026-05-19 15:15:37 -04:00
|
|
|
btn = page.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
|
|
|
|
|
self.assertTrue(btn.is_displayed())
|
2026-05-19 15:48:07 -04:00
|
|
|
self.assertIn("FREE", btn.text.upper())
|
2026-05-19 15:15:37 -04:00
|
|
|
self.assertIn("DRAW", btn.text.upper())
|
|
|
|
|
self.assertIn("btn-primary", btn.get_attribute("class"))
|
|
|
|
|
|
|
|
|
|
# ── Test 2 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_landing_renders_six_chair_seats_labeled_1C_to_6C(self):
|
|
|
|
|
"""All 6 chair positions render w. labels 1C-6C (placeholder for
|
|
|
|
|
friend-invite). CSS class `.table-seat` is preserved so the SCSS
|
2026-05-19 15:48:07 -04:00
|
|
|
positioning rules (data-slot=N) carry over from the room shell.
|
|
|
|
|
Each seat starts w. a red `.fa-ban` status icon (empty)."""
|
2026-05-19 15:15:37 -04:00
|
|
|
self.create_pre_authenticated_session(self.email)
|
|
|
|
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
|
|
|
|
seats = self.wait_for(
|
|
|
|
|
lambda: self._six_seats()
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(len(seats), 6)
|
2026-05-19 15:48:07 -04:00
|
|
|
for n, seat in enumerate(seats, start=1):
|
2026-05-19 15:15:37 -04:00
|
|
|
with self.subTest(slot=n):
|
2026-05-19 15:48:07 -04:00
|
|
|
label = "".join(seat.text.upper().split())
|
|
|
|
|
self.assertIn(f"{n}C", label)
|
|
|
|
|
# Each seat carries the red ban status icon initially.
|
|
|
|
|
seat.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".position-status-icon.fa-ban"
|
|
|
|
|
)
|
2026-05-19 15:15:37 -04:00
|
|
|
|
|
|
|
|
def _six_seats(self):
|
|
|
|
|
seats = self.browser.find_elements(
|
|
|
|
|
By.CSS_SELECTOR, ".my-sea-page[data-phase='landing'] .table-seat"
|
|
|
|
|
)
|
|
|
|
|
if len(seats) != 6:
|
|
|
|
|
raise AssertionError(f"expected 6 seats, got {len(seats)}")
|
|
|
|
|
return seats
|
|
|
|
|
|
|
|
|
|
# ── Test 3 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-19 15:48:07 -04:00
|
|
|
def test_free_draw_click_seats_user_in_1C_then_swaps_phase(self):
|
|
|
|
|
"""Click FREE DRAW → seat 1C transitions to `.seated` w. fa-ban
|
|
|
|
|
swapped for fa-circle-check (visible to the user during the
|
|
|
|
|
~800ms animation delay); other seats remain empty; then the
|
|
|
|
|
page's data-phase swaps to 'picker' so iter 2's content can
|
|
|
|
|
take over. Single-user instance for now → user always gets the
|
|
|
|
|
lowest-numeral seat (1C)."""
|
2026-05-19 15:15:37 -04:00
|
|
|
self.create_pre_authenticated_session(self.email)
|
|
|
|
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
|
|
|
|
btn = self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
|
|
|
|
|
)
|
|
|
|
|
btn.click()
|
2026-05-19 15:48:07 -04:00
|
|
|
# Seat 1C goes seated + icon swaps. Other seats unchanged.
|
|
|
|
|
seat1 = self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".table-seat[data-slot='1'].seated"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
seat1.find_element(By.CSS_SELECTOR, ".position-status-icon.fa-circle-check")
|
|
|
|
|
# Seats 2-6 retain the .fa-ban icon (still empty).
|
|
|
|
|
for n in range(2, 7):
|
|
|
|
|
with self.subTest(slot=n):
|
|
|
|
|
other = self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR, f".table-seat[data-slot='{n}']"
|
|
|
|
|
)
|
|
|
|
|
self.assertNotIn("seated", other.get_attribute("class"))
|
|
|
|
|
other.find_element(By.CSS_SELECTOR, ".position-status-icon.fa-ban")
|
|
|
|
|
# After the seat animation, data-phase swaps to picker + landing hides.
|
2026-05-19 15:15:37 -04:00
|
|
|
self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
landing = self.browser.find_element(By.CSS_SELECTOR, ".my-sea-landing")
|
|
|
|
|
self.assertFalse(landing.is_displayed())
|
|
|
|
|
|
|
|
|
|
# ── Test 4 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_brief_banner_renders_when_no_deck_equipped(self):
|
|
|
|
|
"""No equipped deck → the same 'Default deck warning' Brief
|
|
|
|
|
banner from my-sign fires (lifted verbatim). Tagged w. a my-sea-
|
|
|
|
|
specific class so FTs can disambiguate from any other Briefs."""
|
|
|
|
|
self.gamer.equipped_deck = None
|
|
|
|
|
self.gamer.save(update_fields=["equipped_deck"])
|
|
|
|
|
self.create_pre_authenticated_session(self.email)
|
|
|
|
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
|
|
|
|
banner = self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR, ".my-sea-intro-banner"
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("Default deck warning", banner.text)
|
|
|
|
|
self.assertIn("no deck is equipped", banner.text)
|
|
|
|
|
self.assertIn("Shabby Cardstock", banner.text)
|
|
|
|
|
|
|
|
|
|
# ── Test 5 ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def test_no_brief_banner_when_deck_equipped(self):
|
|
|
|
|
"""User w. an equipped deck → no Default-deck-warning Brief on
|
|
|
|
|
landing. Auto-equip via the User post_save signal handles this
|
|
|
|
|
for fresh users; assertion guards against accidental render of
|
|
|
|
|
the banner when the condition shouldn't fire."""
|
|
|
|
|
self.create_pre_authenticated_session(self.email)
|
|
|
|
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
|
|
|
|
self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
len(self.browser.find_elements(By.CSS_SELECTOR, ".my-sea-intro-banner")),
|
|
|
|
|
0,
|
|
|
|
|
)
|