My Sea iter 6a: gatekeeper page + INSERT/REFUND/PAID DRAW endpoints + MySeaDraw deposit fields + _select_my_sea_token / debit_my_sea_token helpers (CARTE blocked, COIN 24h cooldown not 7-day) + Sprint 6 FT skeleton — Sprint 5 iter 6a of My Sea roadmap — TDD
First of three Sprint 6 commits per [[sprint-my-sea-iter-6-plan]]. Replaces the iter-4c 404 stub at `/gameboard/my-sea/gate/` w. a real token-deposit-to-redraw UI. Iter 6b will wire the navbar GATE VIEW swap + landing PAID DRAW state + seat-1 persistence; iter 6c will land the bud-btn stub. ## Server `MySeaDraw` gains two fields: `deposit_token_id` (int, nullable) + `deposit_reserved_at` (datetime, nullable). Migration 0002. The row plays triple duty now: hand storage + 24h quota tracker + deposit reservation slot. `_select_my_sea_token(user)` mirrors `apps.epic.models.select_token` priority (PASS > COIN > FREE > TITHE) w. two adaptations: - CARTE excluded outright (door-spell trinket, not valid for my-sea draws). - COIN cooldown-respecting: filters out COINs w. `next_ready_at > now`. Standard `select_token` doesn't apply this filter — room logic unchanged. `debit_my_sea_token(user, token)` is the my-sea variant of `apps.epic.models.debit_token`: - CARTE → ValueError (defensive; caller validates upstream). - COIN: `next_ready_at = now + 24h` (not 7-day room cycle) + unequip from kit if equipped. - PASS: no consumption (auto-admit, unlimited redraws). - FREE / TITHE: deleted. `my_sea_gate` view replaces the 404 stub. Renders the gatekeeper template w. branching on `deposit_reserved` (token reserved on row vs not). `my_sea_insert_token` POST: picks a token via `_select_my_sea_token` + sets `deposit_token_id + deposit_reserved_at`. Creates the row if missing (so a fresh user can deposit without first using their free draw). Idempotent w.r.t. an already-reserved deposit. `my_sea_refund_token` POST: clears deposit fields. Token isn't consumed at INSERT (refund-aware design), so this is purely a row update — no inventory side effects. `my_sea_paid_draw` POST: commits via `debit_my_sea_token` + resets row (hand=[], created_at=now, deposit fields cleared). Redirects to `/gameboard/my-sea/` for a fresh quota cycle. ## Template + UX `apps/gameboard/my_sea_gate.html` (new) — per user spec 2026-05-20, the gatekeeper is a darkened-modal-over-`--duoUser` bg matching the room gatekeeper's chrome (`.gate-backdrop` + `.gate-overlay` + `.gate-modal`). No hex / chair-seats — those live on the my-sea picker page itself; the gatekeeper is a transient in-flight UI for token deposit. Coin-slot rails (mirrors room's `.token-slot`): - Pre-deposit: form-wrapped `.token-rails` button → POSTs to `my_sea_insert_token`. Coin-panel labels read INSERT TOKEN TO PLAY. - Post-deposit: rails inert (no form); `.token-return-btn` form → POSTs to `my_sea_refund_token`. Coin-panel labels swap to PUSH TO RETURN. - Post-deposit: PAID DRAW btn (`#id_my_sea_paid_draw_btn`, `.btn-primary`) → POSTs to `my_sea_paid_draw`. Mirrors the room's PICK ROLES btn shape. SCSS minimal — page bg `rgba(--duoUser, 1)` on `.my-sea-page[data-phase="gate"]`; everything else reuses the room gatekeeper's existing rules. ## FT skeleton Per user TDD directive (2026-05-20: "Also via TDD so if we run out we're adhering to FT-described behavior"), wrote the FULL Sprint 6 FT skeleton up front (covers iter 6a + 6b + 6c). Five new FT classes in `test_game_my_sea.py`: - `MySeaGatekeeperPageTest` (5 tests) — iter 6a; pre-deposit / INSERT / REFUND / PAID DRAW paths. - `MySeaLandingPaidDrawTest` (1 test) — iter 6b; landing renders PAID DRAW btn when deposit reserved (red until iter 6b lands). - `MySeaNavbarGateViewTest` (1 test) — iter 6b; navbar GATE VIEW swap (red until iter 6b). - `MySeaSeatOnePersistenceTest` (2 tests) — iter 6b; seat 1 banned for fresh user + empty-hand active draw (red until iter 6b). - `MySeaBudBtnStubTest` (2 tests) — iter 6c; panel opens + OK shows coming-soon Brief (red until iter 6c). ## ITs (iter 6a — 22 new + 153 total green) - `MySeaGateViewTest` (4) — view branching pre/post deposit. - `MySeaInsertTokenViewTest` (4) — row creation, existing row, idempotency, GET=405. - `MySeaRefundTokenViewTest` (3) — clears fields, no token consumption, idempotent. - `MySeaPaidDrawViewTest` (6) — FREE consumed, COIN cooldown + unequip, PASS no-op, hand reset, created_at reset, redirect. - `SelectMySeaTokenTest` (3) — CARTE excluded, COIN cooldown excluded, PASS priority for staff. - `DebitMySeaTokenTest` (4) — CARTE ValueError, FREE/TITHE consumed, PASS preserved. ## Trap caught Existing User `post_save` signal auto-creates COIN + FREE tokens (`apps.lyric.models:309`). Sprint 6 ITs that assert "user has only the token I seeded" must `self.user.tokens.all().delete()` after User.create. Without it, `_select_my_sea_token` returns the auto-COIN instead of None for the CARTE-excluded test. Worth a future feedback memory if it bites again. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0 on 2026-05-20 06:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gameboard', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='myseadraw',
|
||||
name='deposit_reserved_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='myseadraw',
|
||||
name='deposit_token_id',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -55,6 +55,15 @@ class MySeaDraw(models.Model):
|
||||
significator_id = models.IntegerField()
|
||||
significator_reversed = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(default=timezone.now)
|
||||
# Sprint 5 iter 6a — gatekeeper deposit lifecycle. A token can be
|
||||
# RESERVED on the row (refundable until the user clicks PAID DRAW)
|
||||
# via the my-sea gatekeeper at /gameboard/my-sea/gate/. PAID DRAW
|
||||
# commits the deposit + resets the row (`hand=[]`, `created_at=now`,
|
||||
# both deposit_* fields back to None), starting a fresh 24h quota
|
||||
# cycle paid for by the deposited token. See `_select_my_sea_token`
|
||||
# + `debit_my_sea_token` for the priority chain + per-type rules.
|
||||
deposit_token_id = models.IntegerField(null=True, blank=True)
|
||||
deposit_reserved_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
@@ -104,6 +113,70 @@ class MySeaDraw(models.Model):
|
||||
return deleted
|
||||
|
||||
|
||||
def _select_my_sea_token(user):
|
||||
"""Token-picker for the my-sea gatekeeper. Mirrors `apps.epic.models.
|
||||
select_token` priority chain (PASS → COIN → FREE → TITHE) w. two
|
||||
iter-6a tweaks per user spec 2026-05-20:
|
||||
|
||||
- **CARTE excluded.** The door-spell trinket isn't a valid token
|
||||
for paying my-sea draws.
|
||||
- **COIN cooldown-respecting.** After a my-sea PAID DRAW debits a
|
||||
COIN, `debit_my_sea_token` sets `next_ready_at = now + 24h` (not
|
||||
the room's 7-day window). A cooldown'd COIN is unavailable to
|
||||
this picker; standard `select_token` ignores `next_ready_at` so
|
||||
room logic stays untouched (intentional: my-sea is the only path
|
||||
that respects this cooldown).
|
||||
"""
|
||||
from django.db.models import Q
|
||||
from apps.lyric.models import Token
|
||||
now = timezone.now()
|
||||
if user.is_staff:
|
||||
pass_token = user.tokens.filter(token_type=Token.PASS).first()
|
||||
if pass_token:
|
||||
return pass_token
|
||||
coin = user.tokens.filter(
|
||||
token_type=Token.COIN, current_room__isnull=True,
|
||||
).filter(
|
||||
Q(next_ready_at__isnull=True) | Q(next_ready_at__lte=now),
|
||||
).first()
|
||||
if coin:
|
||||
return coin
|
||||
free = user.tokens.filter(
|
||||
token_type=Token.FREE, expires_at__gt=now,
|
||||
).order_by("expires_at").first()
|
||||
if free:
|
||||
return free
|
||||
return user.tokens.filter(token_type=Token.TITHE).first()
|
||||
|
||||
|
||||
def debit_my_sea_token(user, token):
|
||||
"""Commit a my-sea-deposited token. Adapted from `apps.epic.models.
|
||||
debit_token` for solo my-sea per user spec 2026-05-20:
|
||||
|
||||
- **CARTE → ValueError.** Defensive guard; caller should validate
|
||||
upstream via `_select_my_sea_token`.
|
||||
- **COIN.** No `current_room` (there is no room context); instead,
|
||||
set `next_ready_at = now + 24h` so the next-pickup gate kicks in.
|
||||
Unequip from the kit if equipped (parity w. room's COIN behavior).
|
||||
- **PASS.** No consumption — auto-admit. Stays equipped.
|
||||
- **FREE / TITHE.** Consumed (deleted).
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from apps.lyric.models import Token
|
||||
if token.token_type == Token.CARTE:
|
||||
raise ValueError("CARTE cannot pay for a my-sea draw")
|
||||
if token.token_type == Token.COIN:
|
||||
token.next_ready_at = timezone.now() + timedelta(hours=24)
|
||||
token.save(update_fields=["next_ready_at"])
|
||||
if user.equipped_trinket_id == token.pk:
|
||||
user.equipped_trinket = None
|
||||
user.save(update_fields=["equipped_trinket"])
|
||||
elif token.token_type == Token.PASS:
|
||||
pass
|
||||
else:
|
||||
token.delete()
|
||||
|
||||
|
||||
def active_draw_for(user):
|
||||
"""Return the user's most-recent draw within the quota window, or
|
||||
None. Single source of truth for "does the user have an active draw"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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
|
||||
@@ -1368,21 +1370,325 @@ class MySeaViewWithPartialHandTest(TestCase):
|
||||
self.assertNotIn("my-sea-picker--locked", m.group(1))
|
||||
|
||||
|
||||
class MySeaGateStubViewTest(TestCase):
|
||||
"""Sprint 5 iter 4c — placeholder for the Sprint 6 gatekeeper. Returns
|
||||
a 404 so the template-side GATE VIEW button URL resolves but the
|
||||
actual gatekeeper UX rides Sprint 6."""
|
||||
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)
|
||||
|
||||
def test_gate_view_returns_404(self):
|
||||
response = self.client.get(reverse("my_sea_gate"))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
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"))
|
||||
# @login_required redirects before the 404 path runs.
|
||||
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_clears_deposit_fields_and_resets_created_at(self):
|
||||
old_created = self.draw.created_at
|
||||
# Push old created_at back so reset is observable.
|
||||
from datetime import timedelta
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
MySeaDraw.objects.filter(pk=self.draw.pk).update(
|
||||
created_at=old_created - timedelta(hours=12),
|
||||
)
|
||||
self.client.post(self.url)
|
||||
self.draw.refresh_from_db()
|
||||
self.assertIsNone(self.draw.deposit_token_id)
|
||||
self.assertIsNone(self.draw.deposit_reserved_at)
|
||||
self.assertGreater(self.draw.created_at, old_created - timedelta(hours=12))
|
||||
|
||||
def test_paid_draw_resets_hand_to_empty(self):
|
||||
# Even if hand is non-empty, PAID DRAW wipes it (fresh draw cycle).
|
||||
from apps.epic.models import TarotCard
|
||||
cards = list(TarotCard.objects.exclude(id=self.target.id)[:2])
|
||||
self.draw.hand = [
|
||||
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
|
||||
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
|
||||
]
|
||||
self.draw.save(update_fields=["hand"])
|
||||
self.client.post(self.url)
|
||||
self.draw.refresh_from_db()
|
||||
self.assertEqual(self.draw.hand, [])
|
||||
|
||||
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
|
||||
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 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.
|
||||
self.user.tokens.all().delete()
|
||||
|
||||
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):
|
||||
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.assertEqual(_select_my_sea_token(self.user), pass_tok)
|
||||
|
||||
|
||||
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
|
||||
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())
|
||||
|
||||
@@ -17,5 +17,8 @@ urlpatterns = [
|
||||
path('my-sea/lock', views.my_sea_lock, name='my_sea_lock'),
|
||||
path('my-sea/delete', views.my_sea_delete, name='my_sea_delete'),
|
||||
path('my-sea/gate/', views.my_sea_gate, name='my_sea_gate'),
|
||||
path('my-sea/insert', views.my_sea_insert_token, name='my_sea_insert_token'),
|
||||
path('my-sea/refund', views.my_sea_refund_token, name='my_sea_refund_token'),
|
||||
path('my-sea/paid-draw', views.my_sea_paid_draw, name='my_sea_paid_draw'),
|
||||
]
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ from django.utils import timezone
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||
from .models import HAND_SIZE_BY_SPREAD, MySeaDraw, active_draw_for
|
||||
from .models import (
|
||||
HAND_SIZE_BY_SPREAD, MySeaDraw, active_draw_for,
|
||||
_select_my_sea_token, debit_my_sea_token,
|
||||
)
|
||||
|
||||
|
||||
def _annotate_deck_in_use(decks, user):
|
||||
@@ -361,10 +364,112 @@ def my_sea_delete(request):
|
||||
|
||||
@login_required(login_url="/")
|
||||
def my_sea_gate(request):
|
||||
"""Stub for the Sprint 6 gatekeeper. Renders a 404 for now — the
|
||||
button-target placeholder lets the template's GATE VIEW UX wire up
|
||||
in advance; Sprint 6 will replace this w. the token-deposit flow."""
|
||||
return HttpResponse(status=404)
|
||||
"""Sprint 6 iter 6a — solo my-sea gatekeeper. Mirrors the room's
|
||||
`_gatekeeper.html` structure (coin-slot rails + refund affordance)
|
||||
adapted for 1-user, 1-token-per-redraw semantics. The user spends a
|
||||
token (PASS/COIN/FREE/TITHE — CARTE excluded) to acquire a fresh
|
||||
24h quota cycle after their daily free draw is spent.
|
||||
|
||||
Branches on `MySeaDraw.deposit_token_id`:
|
||||
- None (no deposit yet) → INSERT TOKEN TO PLAY rails are active.
|
||||
- non-None (deposit reserved) → refund affordance + PAID DRAW btn.
|
||||
"""
|
||||
active_draw = active_draw_for(request.user)
|
||||
sig_card, sig_reversed = _resolve_sig(request.user, active_draw)
|
||||
deposit_reserved = (
|
||||
active_draw is not None and active_draw.deposit_token_id is not None
|
||||
)
|
||||
hand_non_empty = active_draw is not None and bool(active_draw.hand)
|
||||
return render(request, "apps/gameboard/my_sea_gate.html", {
|
||||
"user_has_sig": sig_card is not None,
|
||||
"significator": sig_card,
|
||||
"significator_reversed": sig_reversed,
|
||||
"active_draw": active_draw,
|
||||
"deposit_reserved": deposit_reserved,
|
||||
"hand_non_empty": hand_non_empty,
|
||||
"page_class": "page-gameboard page-my-sea page-my-sea-gate",
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
@require_POST
|
||||
def my_sea_insert_token(request):
|
||||
"""Reserve the user's next-priority token on their MySeaDraw row.
|
||||
Idempotent w.r.t. an already-reserved deposit — re-posting is a no-op
|
||||
rather than double-debit. Creates the row if none exists (so a fresh
|
||||
user can hit the gatekeeper without first using their free draw)."""
|
||||
active_draw = active_draw_for(request.user)
|
||||
if active_draw is None:
|
||||
# No active row yet — create a quota tracker row w. empty hand
|
||||
# so the deposit has something to attach to. This also commits
|
||||
# the user's free-draw quota for the day (since `active_draw_
|
||||
# for` will now return this row).
|
||||
sig_id = request.user.significator_id
|
||||
if sig_id is None:
|
||||
return redirect("my_sea_gate")
|
||||
active_draw = MySeaDraw.objects.create(
|
||||
user=request.user,
|
||||
spread="situation-action-outcome",
|
||||
hand=[],
|
||||
significator_id=sig_id,
|
||||
significator_reversed=request.user.significator_reversed,
|
||||
)
|
||||
if active_draw.deposit_token_id is not None:
|
||||
return redirect("my_sea_gate")
|
||||
token = _select_my_sea_token(request.user)
|
||||
if token is None:
|
||||
return redirect("my_sea_gate")
|
||||
active_draw.deposit_token_id = token.pk
|
||||
active_draw.deposit_reserved_at = timezone.now()
|
||||
active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
|
||||
return redirect("my_sea_gate")
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
@require_POST
|
||||
def my_sea_refund_token(request):
|
||||
"""Clear the user's deposit reservation. Token wasn't actually
|
||||
debited at INSERT (refund-aware design), so this is purely a row
|
||||
update — no side effects on the user's inventory."""
|
||||
active_draw = active_draw_for(request.user)
|
||||
if active_draw is not None and active_draw.deposit_token_id is not None:
|
||||
active_draw.deposit_token_id = None
|
||||
active_draw.deposit_reserved_at = None
|
||||
active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
|
||||
return redirect("my_sea_gate")
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
@require_POST
|
||||
def my_sea_paid_draw(request):
|
||||
"""Commit the deposited token + reset the row for a fresh quota
|
||||
cycle. The token is debited via `debit_my_sea_token` (FREE/TITHE
|
||||
consumed; COIN 24h cooldown + unequipped; PASS no-op). Hand wiped,
|
||||
`created_at` reset to now, deposit fields cleared. User redirects
|
||||
back to /gameboard/my-sea/ ready to draw a fresh hand."""
|
||||
from apps.lyric.models import Token
|
||||
active_draw = active_draw_for(request.user)
|
||||
if active_draw is None or active_draw.deposit_token_id is None:
|
||||
return redirect("my_sea")
|
||||
token = Token.objects.filter(
|
||||
pk=active_draw.deposit_token_id, user=request.user,
|
||||
).first()
|
||||
if token is None:
|
||||
# Token vanished between reserve + commit (unlikely w. solo
|
||||
# flow but defensive). Clear deposit + bounce to my-sea.
|
||||
active_draw.deposit_token_id = None
|
||||
active_draw.deposit_reserved_at = None
|
||||
active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
|
||||
return redirect("my_sea")
|
||||
debit_my_sea_token(request.user, token)
|
||||
active_draw.hand = []
|
||||
active_draw.created_at = timezone.now()
|
||||
active_draw.deposit_token_id = None
|
||||
active_draw.deposit_reserved_at = None
|
||||
active_draw.save(update_fields=[
|
||||
"hand", "created_at", "deposit_token_id", "deposit_reserved_at",
|
||||
])
|
||||
return redirect("my_sea")
|
||||
|
||||
|
||||
def _my_sea_deck_data(user, exclude_id=None):
|
||||
|
||||
@@ -1178,3 +1178,380 @@ class MySeaLockHandTest(FunctionalTest):
|
||||
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_draw_sea_btn")),
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
# ── Sprint 6 — my-sea gatekeeper (token-deposit-to-redraw within 24h) ─────────
|
||||
# FT skeleton written TDD-first per user spec 2026-05-20. See
|
||||
# [[sprint-my-sea-iter-6-plan]] for the full spec; iters 6a/6b/6c break
|
||||
# the work into three commits but the FTs describe the user-facing
|
||||
# behavior end-to-end so the impl can converge against them.
|
||||
|
||||
|
||||
class MySeaGatekeeperPageTest(FunctionalTest):
|
||||
"""Sprint 6 iter 6a — `/gameboard/my-sea/gate/` renders the solo
|
||||
gatekeeper UI. Coin-slot rails (INSERT TOKEN TO PLAY) + 6-chair hex
|
||||
(seat 1 always reserved for owner, others banned). One-token-per-
|
||||
draw, refundable until PAID DRAW commits."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_seed_earthman_sig_pile()
|
||||
_seed_gameboard_applets()
|
||||
self.email = "gate@test.io"
|
||||
self.gamer = User.objects.create(email=self.email)
|
||||
_assign_sig(self.gamer)
|
||||
# Seed a FREE token so the gatekeeper has something to deposit.
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone as dj_tz
|
||||
from apps.lyric.models import Token
|
||||
Token.objects.create(
|
||||
user=self.gamer, token_type=Token.FREE,
|
||||
expires_at=dj_tz.now() + timedelta(days=30),
|
||||
)
|
||||
|
||||
def _save_empty_hand_draw(self):
|
||||
"""Quota-spent state: an active MySeaDraw row w. empty hand
|
||||
(post-DEL or post-completion-DEL). This is the canonical state
|
||||
where the gatekeeper is meaningful."""
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
return MySeaDraw.objects.create(
|
||||
user=self.gamer, spread="situation-action-outcome",
|
||||
significator_id=self.gamer.significator_id, hand=[],
|
||||
)
|
||||
|
||||
def test_gatekeeper_page_renders_token_rails_in_empty_state(self):
|
||||
"""No deposit yet → coin-slot shows INSERT TOKEN TO PLAY + the
|
||||
rails are active (click-target). No refund btn yet."""
|
||||
self._save_empty_hand_draw()
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||||
rails_form = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, "form[action$='/my-sea/insert']"
|
||||
)
|
||||
)
|
||||
rails_form.find_element(By.CSS_SELECTOR, "button.token-rails")
|
||||
# No refund btn or PAID DRAW yet.
|
||||
self.assertEqual(
|
||||
len(self.browser.find_elements(
|
||||
By.CSS_SELECTOR, "form[action$='/my-sea/refund']"
|
||||
)),
|
||||
0,
|
||||
)
|
||||
self.assertEqual(
|
||||
len(self.browser.find_elements(
|
||||
By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
|
||||
)),
|
||||
0,
|
||||
)
|
||||
|
||||
def test_gatekeeper_renders_six_chair_seats_with_seat1_seated(self):
|
||||
"""Hex w. 6 chair seats; seat 1 is the owner's (always `.seated`
|
||||
when quota is committed); seats 2-6 carry `.fa-ban` (placeholders
|
||||
for the future friend-invite feature)."""
|
||||
self._save_empty_hand_draw()
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||||
self.wait_for(
|
||||
lambda: self._assert_seats(6)
|
||||
)
|
||||
seat1 = self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".table-seat[data-slot='1']"
|
||||
)
|
||||
self.assertIn("seated", seat1.get_attribute("class"))
|
||||
seat1.find_element(By.CSS_SELECTOR, ".fa-circle-check")
|
||||
|
||||
def _assert_seats(self, count):
|
||||
seats = self.browser.find_elements(By.CSS_SELECTOR, ".table-seat")
|
||||
if len(seats) != count:
|
||||
raise AssertionError(f"expected {count} seats, got {len(seats)}")
|
||||
return seats
|
||||
|
||||
def test_insert_token_reserves_deposit_and_reveals_paid_draw_btn(self):
|
||||
"""Click INSERT TOKEN → server reserves the user's next-priority
|
||||
token on the MySeaDraw row; gatekeeper re-renders w. refund btn
|
||||
+ `#id_my_sea_paid_draw_btn` visible."""
|
||||
self._save_empty_hand_draw()
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||||
rails = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
"form[action$='/my-sea/insert'] button.token-rails",
|
||||
)
|
||||
)
|
||||
rails.click()
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
|
||||
)
|
||||
)
|
||||
# Refund affordance is now present.
|
||||
self.browser.find_element(
|
||||
By.CSS_SELECTOR, "form[action$='/my-sea/refund']"
|
||||
)
|
||||
# Server-side: MySeaDraw row has deposit_token_id set.
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
draw = MySeaDraw.objects.get(user=self.gamer)
|
||||
self.assertIsNotNone(draw.deposit_token_id)
|
||||
|
||||
def test_refund_clears_deposit_and_returns_to_empty_state(self):
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
self._save_empty_hand_draw()
|
||||
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,
|
||||
"form[action$='/my-sea/insert'] button.token-rails",
|
||||
)
|
||||
).click()
|
||||
refund = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
"form[action$='/my-sea/refund'] button",
|
||||
)
|
||||
)
|
||||
refund.click()
|
||||
# After refund, INSERT TOKEN form is back; PAID DRAW gone.
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
"form[action$='/my-sea/insert']",
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
len(self.browser.find_elements(
|
||||
By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
|
||||
)),
|
||||
0,
|
||||
)
|
||||
draw = MySeaDraw.objects.get(user=self.gamer)
|
||||
self.assertIsNone(draw.deposit_token_id)
|
||||
|
||||
def test_paid_draw_commits_token_and_redirects_to_picker(self):
|
||||
"""PAID DRAW commits the deposited token (FREE token gets
|
||||
consumed → user's token count drops by 1); server resets the
|
||||
MySeaDraw row (hand=[], created_at=now, deposit cleared); user
|
||||
lands back on /gameboard/my-sea/ ready to draw a fresh hand."""
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
from apps.lyric.models import Token
|
||||
self._save_empty_hand_draw()
|
||||
free_count_before = Token.objects.filter(
|
||||
user=self.gamer, token_type=Token.FREE,
|
||||
).count()
|
||||
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,
|
||||
"form[action$='/my-sea/insert'] button.token-rails",
|
||||
)
|
||||
).click()
|
||||
paid_draw = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
|
||||
)
|
||||
)
|
||||
paid_draw.click()
|
||||
# Redirect lands on /gameboard/my-sea/ (landing or picker).
|
||||
self.wait_for(
|
||||
lambda: self.assertIn("/gameboard/my-sea/", self.browser.current_url)
|
||||
)
|
||||
# FREE token consumed.
|
||||
self.assertEqual(
|
||||
Token.objects.filter(user=self.gamer, token_type=Token.FREE).count(),
|
||||
free_count_before - 1,
|
||||
)
|
||||
# MySeaDraw row: hand reset to empty, deposit cleared, fresh quota.
|
||||
draw = MySeaDraw.objects.get(user=self.gamer)
|
||||
self.assertEqual(draw.hand, [])
|
||||
self.assertIsNone(draw.deposit_token_id)
|
||||
|
||||
|
||||
class MySeaLandingPaidDrawTest(FunctionalTest):
|
||||
"""Sprint 6 iter 6b — landing center-btn state machine extended w.
|
||||
PAID DRAW. After depositing in the gatekeeper, refresh / navigate
|
||||
back to /gameboard/my-sea/ → landing renders PAID DRAW (not GATE
|
||||
VIEW) at the hex center."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_seed_earthman_sig_pile()
|
||||
_seed_gameboard_applets()
|
||||
self.email = "landpaid@test.io"
|
||||
self.gamer = User.objects.create(email=self.email)
|
||||
_assign_sig(self.gamer)
|
||||
|
||||
def test_landing_shows_paid_draw_btn_when_deposit_reserved(self):
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone as dj_tz
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
from apps.lyric.models import Token
|
||||
free_tok = Token.objects.create(
|
||||
user=self.gamer, token_type=Token.FREE,
|
||||
expires_at=dj_tz.now() + timedelta(days=30),
|
||||
)
|
||||
MySeaDraw.objects.create(
|
||||
user=self.gamer, spread="situation-action-outcome",
|
||||
significator_id=self.gamer.significator_id, hand=[],
|
||||
deposit_token_id=free_tok.pk,
|
||||
deposit_reserved_at=dj_tz.now(),
|
||||
)
|
||||
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_my_sea_paid_draw_btn"
|
||||
)
|
||||
)
|
||||
# FREE DRAW + GATE VIEW are NOT shown when a deposit is reserved.
|
||||
self.assertEqual(
|
||||
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_draw_sea_btn")),
|
||||
0,
|
||||
)
|
||||
self.assertEqual(
|
||||
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_my_sea_gate_view_btn")),
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
class MySeaNavbarGateViewTest(FunctionalTest):
|
||||
"""Sprint 6 iter 6b — navbar CONT GAME swaps to GATE VIEW whenever
|
||||
the user is on `body.page-my-sea`. Always reachable, regardless of
|
||||
quota state — clicking takes the user to /gameboard/my-sea/gate/."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_seed_earthman_sig_pile()
|
||||
_seed_gameboard_applets()
|
||||
self.email = "navgate@test.io"
|
||||
self.gamer = User.objects.create(email=self.email)
|
||||
_assign_sig(self.gamer)
|
||||
|
||||
def test_navbar_renders_gate_view_btn_on_my_sea_page(self):
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
nav = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".navbar")
|
||||
)
|
||||
# GATE VIEW btn present, CONT GAME btn not.
|
||||
nav.find_element(By.CSS_SELECTOR, "#id_navbar_gate_view_btn")
|
||||
self.assertEqual(
|
||||
len(nav.find_elements(By.CSS_SELECTOR, "#id_navbar_cont_game_btn")),
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
class MySeaSeatOnePersistenceTest(FunctionalTest):
|
||||
"""Sprint 6 iter 6b — seat 1 (the owner's reserved chair) renders
|
||||
`.seated` whenever the user's hand is non-empty (mid-draw or
|
||||
complete). DEL empties the hand → seat 1 reverts to `.fa-ban`."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_seed_earthman_sig_pile()
|
||||
_seed_gameboard_applets()
|
||||
self.email = "seat1@test.io"
|
||||
self.gamer = User.objects.create(email=self.email)
|
||||
_assign_sig(self.gamer)
|
||||
|
||||
def test_seat_1_banned_for_fresh_user_no_quota(self):
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
seat1 = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".table-seat[data-slot='1']"
|
||||
)
|
||||
)
|
||||
self.assertNotIn("seated", seat1.get_attribute("class"))
|
||||
seat1.find_element(By.CSS_SELECTOR, ".fa-ban")
|
||||
|
||||
def test_seat_1_banned_when_active_draw_has_empty_hand(self):
|
||||
"""DEL leaves the row but wipes the hand; seat 1 reverts to
|
||||
banned (per user spec 2026-05-20: seat 1 tied to hand non-empty,
|
||||
NOT to active_draw existence)."""
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
MySeaDraw.objects.create(
|
||||
user=self.gamer, spread="situation-action-outcome",
|
||||
significator_id=self.gamer.significator_id, hand=[],
|
||||
)
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
seat1 = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".table-seat[data-slot='1']"
|
||||
)
|
||||
)
|
||||
self.assertNotIn("seated", seat1.get_attribute("class"))
|
||||
|
||||
|
||||
class MySeaBudBtnStubTest(FunctionalTest):
|
||||
"""Sprint 6 iter 6c — bud-btn invite panel rendered on the
|
||||
gatekeeper. Panel opens + autocomplete works (reuses billboard:
|
||||
search_buds), but the OK btn is a no-op stub — POSTs return a
|
||||
'Multiplayer my-sea coming soon' Brief banner. Async invite is
|
||||
deferred to a future sprint."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_seed_earthman_sig_pile()
|
||||
_seed_gameboard_applets()
|
||||
self.email = "bud@test.io"
|
||||
self.gamer = User.objects.create(email=self.email)
|
||||
_assign_sig(self.gamer)
|
||||
# Seed a quota row so the gatekeeper has context.
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
MySeaDraw.objects.create(
|
||||
user=self.gamer, spread="situation-action-outcome",
|
||||
significator_id=self.gamer.significator_id, hand=[],
|
||||
)
|
||||
|
||||
def test_bud_btn_panel_opens_on_gatekeeper(self):
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||||
bud_btn = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_bud_btn")
|
||||
)
|
||||
bud_btn.click()
|
||||
# Panel opens — html.bud-open class is added.
|
||||
self.wait_for(
|
||||
lambda: self.assertIn(
|
||||
"bud-open",
|
||||
self.browser.find_element(By.TAG_NAME, "html").get_attribute("class"),
|
||||
)
|
||||
)
|
||||
self.browser.find_element(By.ID, "id_recipient")
|
||||
|
||||
def test_bud_btn_ok_renders_coming_soon_brief(self):
|
||||
from apps.lyric.models import User as _U
|
||||
# Seed a friend so the OK click has a recipient to "invite".
|
||||
_U.objects.create(email="friend@test.io")
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||||
bud_btn = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_bud_btn")
|
||||
)
|
||||
bud_btn.click()
|
||||
recipient = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_recipient")
|
||||
)
|
||||
# Wait for slide-out animation to settle (per
|
||||
# [[feedback-css-transition-selenium-click-race]]).
|
||||
self.wait_for(
|
||||
lambda: self.assertGreater(
|
||||
self.browser.execute_script(
|
||||
"return document.getElementById('id_bud_panel').getBoundingClientRect().width;"
|
||||
),
|
||||
100,
|
||||
)
|
||||
)
|
||||
recipient.send_keys("friend@test.io")
|
||||
self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
|
||||
).click()
|
||||
# Brief banner appears w. coming-soon copy.
|
||||
brief = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner")
|
||||
)
|
||||
self.assertIn("coming soon", brief.text.lower())
|
||||
|
||||
@@ -285,6 +285,19 @@ body.page-gameboard {
|
||||
background: rgba(var(--priUser), 1);
|
||||
}
|
||||
|
||||
// Sprint 6 iter 6a — gatekeeper page bg + modal chrome. The page bg
|
||||
// is uniform `--duoUser` (matches the hex interior on landing /
|
||||
// picker so the visual transitions read as a continuous surface);
|
||||
// the `.gate-overlay`/`.gate-modal` rules in `_room.scss` already
|
||||
// give us the darkened Gaussian-glass modal centered over it. No hex
|
||||
// or chair-seats on this page — the gatekeeper is a transient in-
|
||||
// flight UI per user spec 2026-05-20.
|
||||
.my-sea-page[data-phase="gate"] {
|
||||
background: rgba(var(--duoUser), 1);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.my-sea-picker {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
72
src/templates/apps/gameboard/my_sea_gate.html
Normal file
72
src/templates/apps/gameboard/my_sea_gate.html
Normal file
@@ -0,0 +1,72 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title_text %}Game Sea Gate{% endblock title_text %}
|
||||
{% block header_text %}<span>Game</span><span>Gate</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
{# Sprint 6 iter 6a — solo my-sea gatekeeper. Token-deposit-to-redraw #}
|
||||
{# within the 24h window after the user's daily free draw is spent. #}
|
||||
{# Layout: `--duoUser` page bg + darkened Gaussian-glass modal centered #}
|
||||
{# over it (mirrors the room gatekeeper's `.gate-overlay` + `.gate- #}
|
||||
{# modal` chrome). No hex / chair-seats — the gatekeeper is a transient #}
|
||||
{# in-flight UI; seats live on the my-sea picker page itself. #}
|
||||
<div class="my-sea-page my-sea-gate-page"
|
||||
data-phase="gate"
|
||||
data-polarity="{% if significator_reversed %}gravity{% else %}levity{% endif %}">
|
||||
|
||||
<div id="id_gate_wrapper" class="my-sea-gate-wrapper">
|
||||
<div class="gate-backdrop"></div>
|
||||
<div class="gate-overlay my-sea-gate-overlay">
|
||||
<div class="gate-modal my-sea-gate-modal" role="dialog" aria-label="My Sea Gatekeeper">
|
||||
|
||||
{# Coin-slot rails — INSERT TOKEN TO PLAY pre-deposit; #}
|
||||
{# PUSH TO RETURN (refund btn) post-deposit. Mirrors #}
|
||||
{# `_gatekeeper.html`'s `.token-slot` shape so the SCSS #}
|
||||
{# from `_room.scss` carries over (rail glow on active #}
|
||||
{# state, claimed state on deposit, etc.). #}
|
||||
<div class="token-slot{% if not deposit_reserved %} active{% else %} pending{% endif %}">
|
||||
{% if not deposit_reserved %}
|
||||
<form method="POST" action="{% url 'my_sea_insert_token' %}" style="display:contents">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="token-rails" aria-label="Insert token to play">
|
||||
<span class="rail"></span>
|
||||
<span class="rail"></span>
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="token-rails">
|
||||
<span class="rail"></span>
|
||||
<span class="rail"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="token-panel">
|
||||
<div class="token-denomination">1</div>
|
||||
<span class="token-insert-label">INSERT TOKEN TO PLAY</span>
|
||||
<span class="token-return-label">PUSH TO RETURN</span>
|
||||
</div>
|
||||
{% if deposit_reserved %}
|
||||
<form method="POST" action="{% url 'my_sea_refund_token' %}" style="display:contents">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="token-return-btn" aria-label="Push to return"></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# PAID DRAW — commits the deposit + redirects to my-sea. #}
|
||||
{# Mirrors the room's PICK ROLES btn shape (`.btn-primary` #}
|
||||
{# alongside the coin-slot in `.gate-top-row`). #}
|
||||
{% if deposit_reserved %}
|
||||
<form method="POST" action="{% url 'my_sea_paid_draw' %}" class="my-sea-paid-draw-form">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
id="id_my_sea_paid_draw_btn"
|
||||
class="btn btn-primary">PAID<br>DRAW</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
Reference in New Issue
Block a user