2026-03-08 15:14:41 -04:00
|
|
|
import lxml.html
|
|
|
|
|
|
2026-03-12 14:23:09 -04:00
|
|
|
from django.test import TestCase
|
2026-03-08 15:14:41 -04:00
|
|
|
|
2026-03-11 00:58:24 -04:00
|
|
|
from apps.applets.models import Applet, UserApplet
|
2026-03-08 15:14:41 -04:00
|
|
|
from apps.lyric.models import Token, User, Wallet
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class WalletViewTest(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create(email="capman@test.io")
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
response = self.client.get("/dashboard/wallet/")
|
|
|
|
|
self.parsed = lxml.html.fromstring(response.content)
|
|
|
|
|
|
|
|
|
|
def test_wallet_page_requires_login(self):
|
|
|
|
|
self.client.logout()
|
|
|
|
|
response = self.client.get("/dashboard/wallet/")
|
|
|
|
|
self.assertRedirects(
|
|
|
|
|
response, "/?next=/dashboard/wallet/", fetch_redirect_response=False
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_wallet_page_renders(self):
|
|
|
|
|
[el] = self.parsed.cssselect("#id_writs_balance")
|
|
|
|
|
self.assertEqual(el.text_content().strip(), "144")
|
|
|
|
|
|
|
|
|
|
def test_wallet_page_shows_esteem_balance(self):
|
|
|
|
|
[el] = self.parsed.cssselect("#id_esteem_balance")
|
|
|
|
|
self.assertEqual(el.text_content().strip(), "0")
|
|
|
|
|
|
|
|
|
|
def test_wallet_page_shows_coin_on_a_string(self):
|
|
|
|
|
[_] = self.parsed.cssselect("#id_coin_on_a_string")
|
|
|
|
|
|
|
|
|
|
def test_wallet_page_shows_free_token(self):
|
2026-03-15 16:57:24 -04:00
|
|
|
[_] = self.parsed.cssselect("#id_free_token")
|
2026-03-08 15:14:41 -04:00
|
|
|
|
|
|
|
|
def test_wallet_page_shows_payment_methods_section(self):
|
|
|
|
|
[_] = self.parsed.cssselect("#id_add_payment_method")
|
|
|
|
|
|
|
|
|
|
def test_wallet_page_shows_stripe_payment_element(self):
|
|
|
|
|
[_] = self.parsed.cssselect("#id_stripe_payment_element")
|
|
|
|
|
|
2026-05-22 01:23:07 -04:00
|
|
|
# Note: the legacy `#id_tithe_token_shop` HTML in Balances was
|
|
|
|
|
# superseded by the dedicated Shop applet in Chunk 5 of
|
|
|
|
|
# [[project-wallet-shop-expansion]]. Shop-applet coverage lives in
|
|
|
|
|
# `WalletTokensAppletAllTrinketsVisibleTest` below + `test_shop_models.py`
|
|
|
|
|
# + `test_shop_views.py`.
|
2026-03-08 15:14:41 -04:00
|
|
|
|
2026-03-11 00:58:24 -04:00
|
|
|
|
2026-05-21 23:07:42 -04:00
|
|
|
class WalletTokensAppletAllTrinketsVisibleTest(TestCase):
|
|
|
|
|
"""Chunk 1 of the Shop applet rollout (2026-05-22) — the Tokens applet
|
|
|
|
|
in `wallet.html` must show every owned trinket-as-token type at once.
|
|
|
|
|
Pre-Chunk-1 the template's `{% if pass_token %} ... {% elif band %}
|
|
|
|
|
... {% elif coin %}` chain hid two of the three from any user holding
|
|
|
|
|
a higher-priority trinket — bad UX since all three are usable at the
|
|
|
|
|
gate (per [[feedback-equip-slot-gates-trinket-use]], the user picks
|
|
|
|
|
WHICH one fires via the equip slot)."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create(email="multitoken@test.io", is_staff=True)
|
|
|
|
|
# Auto-COIN (equipped) + FREE created by post_save signal; PASS auto-
|
|
|
|
|
# granted by the is_staff branch of the same signal. Add the rest.
|
|
|
|
|
Token.objects.create(user=self.user, token_type=Token.BAND)
|
|
|
|
|
Token.objects.create(user=self.user, token_type=Token.CARTE)
|
|
|
|
|
Token.objects.create(user=self.user, token_type=Token.TITHE)
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
response = self.client.get("/dashboard/wallet/")
|
|
|
|
|
self.parsed = lxml.html.fromstring(response.content)
|
|
|
|
|
|
|
|
|
|
def test_wallet_shows_pass_token(self):
|
|
|
|
|
[_] = self.parsed.cssselect("#id_pass_token")
|
|
|
|
|
|
|
|
|
|
def test_wallet_shows_band_token(self):
|
|
|
|
|
[_] = self.parsed.cssselect("#id_band_token")
|
|
|
|
|
|
|
|
|
|
def test_wallet_shows_coin_on_a_string(self):
|
|
|
|
|
[_] = self.parsed.cssselect("#id_coin_on_a_string")
|
|
|
|
|
|
|
|
|
|
def test_wallet_shows_carte_token(self):
|
|
|
|
|
[_] = self.parsed.cssselect("#id_carte_token")
|
|
|
|
|
|
|
|
|
|
def test_wallet_shows_free_token(self):
|
|
|
|
|
[_] = self.parsed.cssselect("#id_free_token")
|
|
|
|
|
|
|
|
|
|
def test_wallet_shows_tithe_token(self):
|
|
|
|
|
[_] = self.parsed.cssselect("#id_tithe_token")
|
|
|
|
|
|
|
|
|
|
def test_view_context_passes_carte(self):
|
|
|
|
|
"""Defense-in-depth: not just the template but the view context too —
|
|
|
|
|
a renamed/refactored template should still receive `carte` in ctx."""
|
|
|
|
|
response = self.client.get("/dashboard/wallet/")
|
|
|
|
|
self.assertEqual(response.context["carte"].token_type, Token.CARTE)
|
|
|
|
|
|
|
|
|
|
def test_view_context_passes_band(self):
|
|
|
|
|
response = self.client.get("/dashboard/wallet/")
|
|
|
|
|
self.assertEqual(response.context["band"].token_type, Token.BAND)
|
|
|
|
|
|
|
|
|
|
def test_non_staff_user_with_carte_still_sees_carte(self):
|
|
|
|
|
"""CARTE has no `is_staff` gating (unlike PASS) — a regular gamer
|
|
|
|
|
holding a CARTE must see it in the Tokens applet."""
|
|
|
|
|
non_staff = User.objects.create(email="grunt@test.io")
|
|
|
|
|
Token.objects.create(user=non_staff, token_type=Token.CARTE)
|
|
|
|
|
self.client.force_login(non_staff)
|
|
|
|
|
response = self.client.get("/dashboard/wallet/")
|
|
|
|
|
parsed = lxml.html.fromstring(response.content)
|
|
|
|
|
[_] = parsed.cssselect("#id_carte_token")
|
|
|
|
|
|
|
|
|
|
|
feat: wallet Shop polish — microtooltip extraction, Shop-first ordering, DRY tooltip styling, writs rebalance, "no expiry" on all items. Visual-pass tweaks landing atop the 5-chunk Shop rollout (commits 8e476f5 → d28cf7b). **Microtooltip extraction**: `.tt-microbutton-portal` (Chunk 4's wrap-inside-`.tt`) replaced w. a sibling `.tt-micro` div on each `.shop-tile`. `wallet.js`'s `initWalletTooltips` clones BOTH into separate portals on hover — `.tt` → `#id_tooltip_portal` (main card), `.tt-micro` → `#id_mini_tooltip_portal` (small italic pill at bottom-right of main, mirroring Game Kit's Equipped/Unequipped/In-Use mini portal). Hover persistence covers both portals + the source tile w. a 200ms grace timer cancelled by mouseenter on any of the 3 zones. Capped items (BAND-owned) render NO btn at all — just "Already owned" microtext (mirrors Game Kit's status-only "Equipped" pill rather than the disabled-× pattern that lived in Chunk 4). **Tooltip-pin on guard open**: `WalletTooltips.pin()` / `.unpin()` exposed on window; `wallet-shop.js`'s BUY click calls `pin()` before `showGuard()` + both `onConfirm` / `onDismiss` callbacks call `unpin()` → the item tooltip stays visible behind the guard's "Buy {name} for ${price}?" prompt instead of orphaning. **Shop-first applet ordering**: new `Applet.display_order` field (default 100, lower = earlier; PK tie-break preserves legacy insertion-order for the existing 3 applets); seed migration sets `wallet-shop.display_order=10` so Shop renders atop Balances/Tokens/Payment. `applet_context()` updated to `.order_by("display_order", "pk")`. New `WalletAppletOrderTest` (2 ITs) pins Shop-first DOM order + view-context list. **DRY tooltip styling**: shop tooltip now uses the same 4-slot `.tt-title` / `.tt-description` / `.tt-shoptalk` / `.tt-expiry` classes as the Tokens row. New `ShopItem.shoptalk` field for the italic flavor line (band-1 = "Unlimited free entry (BYOB)" split out of description; tithes blank). New `ShopItem.tooltip_expiry()` method returns "no expiry" — eternal-stock convention (all current items; seasonal listings could override later). **Writs rebalance**: locked 2026-05-22 — tithe-1 144→12 writs, tithe-5 750→60 writs. Description text updated in lockstep ("1 Tithe Token + 12 Writs" / "5 Tithe Tokens + 60 Writs"). **Badge tweak**: ×N badge shrunk 2rem → 1.5rem + nudged further off-tile (top: -0.7rem, right: -1rem) so most of the underlying icon stays visible. **SCSS**: `.tt-micro` hidden in source DOM (portal-only); `#id_mini_tooltip_portal` mostly mirrors gameboard's mini at `_gameboard.scss:140` but allows BUY-btn label to wrap onto multiple lines (`white-space: normal` on `.tt-buy-btn`); `.tt-already-owned` styled w. `--secUser` italic at 0.85rem to match Game Kit pills. **Migrations** — 5 new: `lyric/0010_repricing_tithe_writs` (writs + description), `lyric/0011_shopitem_shoptalk` (schema), `lyric/0012_seed_shop_shoptalk` (band split), `applets/0012_applet_display_order` (schema), `applets/0013_wallet_shop_display_order` (Shop atop). All idempotent. **TDD** — 5 new ITs across `test_shop_models.py` (`shoptalk` default + per-item assertions, `tooltip_expiry` method, updated tithe writs values, `WalletAppletOrderTest`), 1 new FT (`test_shop_buy_guard_portal_pins_item_tooltip` — programmatically dispatches mouseenter/mouseleave to exercise the pin/unpin race), 3 new Jasmine specs (T6 pin-on-click, T7 unpin-on-confirm, T8 unpin-on-dismiss). Existing FT band-owned assertion switched to `.tt-micro` (no `.tt-buy-btn` present), Jasmine T2 rewritten to assert no btn renders. **3 traps caught** mid-build: (a) multi-line `{# #}` comment leaked into DOM again (cf [[feedback-django-comments-single-line-only]]) — pinned the trap; (b) `spyOn(window, 'fetch')` Jasmine double-spy collision (cf trapped previously); (c) async pollution where `afterEach` restores `window.Stripe=undefined` before `_doBuy`'s continuation hits it — fixed by per-test never-resolving fetch mock. 1211 IT/UT + 9 wallet FTs green; Jasmine SpecRunner verified visually (FT hangs Selenium-side on spec count). Pipeline will sweep all FTs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 02:21:10 -04:00
|
|
|
class WalletAppletOrderTest(TestCase):
|
|
|
|
|
"""The wallet row renders Shop first, then Balances/Tokens/Payment in
|
|
|
|
|
their historical insertion order — pinned via `Applet.display_order`
|
|
|
|
|
(lower = earlier; default 100 + PK tie-break preserves the legacy
|
|
|
|
|
order for the rest). Bug-prevention pin: a future migration that
|
|
|
|
|
renames or reseeds applets must keep wallet-shop at order < 100.
|
|
|
|
|
See [[project-wallet-shop-expansion]] for the locked layout spec."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create(email="layout@test.io")
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
|
|
|
|
|
def test_shop_applet_renders_first_in_wallet_row(self):
|
|
|
|
|
response = self.client.get("/dashboard/wallet/")
|
|
|
|
|
html = response.content.decode()
|
|
|
|
|
shop_pos = html.find('id="id_wallet_shop"')
|
|
|
|
|
balances_pos = html.find('id="id_wallet_balances"')
|
|
|
|
|
tokens_pos = html.find('id_writs_balance') # inside balances applet
|
|
|
|
|
# Shop's id_wallet_shop appears before Balances' id_wallet_balances
|
|
|
|
|
self.assertGreater(shop_pos, 0)
|
|
|
|
|
self.assertGreater(balances_pos, 0)
|
|
|
|
|
self.assertLess(shop_pos, balances_pos)
|
|
|
|
|
self.assertLess(shop_pos, tokens_pos)
|
|
|
|
|
|
|
|
|
|
def test_shop_applet_first_in_context_list(self):
|
|
|
|
|
"""View-context shape pin: `applets` is a list ordered Shop-first."""
|
|
|
|
|
response = self.client.get("/dashboard/wallet/")
|
|
|
|
|
slugs = [e["applet"].slug for e in response.context["applets"]]
|
|
|
|
|
self.assertEqual(slugs[0], "wallet-shop")
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 00:35:55 -04:00
|
|
|
class WalletPassTokenVisibilityTest(TestCase):
|
|
|
|
|
"""PASS is admin-only — the model guard blocks bogus rows from existing
|
|
|
|
|
for non-staff users, but defend the wallet surface too so a future
|
|
|
|
|
code path that bypasses the model (eg. raw SQL backfill) doesn't
|
|
|
|
|
silently leak the trinket into a non-admin's view."""
|
|
|
|
|
|
|
|
|
|
def test_pass_token_in_context_for_staff(self):
|
|
|
|
|
user = User.objects.create(email="staff@test.io", is_staff=True)
|
|
|
|
|
self.client.force_login(user)
|
|
|
|
|
response = self.client.get("/dashboard/wallet/")
|
|
|
|
|
self.assertIsNotNone(response.context["pass_token"])
|
|
|
|
|
|
|
|
|
|
def test_pass_token_absent_for_non_staff(self):
|
|
|
|
|
user = User.objects.create(email="reg@test.io")
|
|
|
|
|
self.client.force_login(user)
|
|
|
|
|
response = self.client.get("/dashboard/wallet/")
|
|
|
|
|
self.assertIsNone(response.context["pass_token"])
|
|
|
|
|
|
|
|
|
|
def test_pass_token_absent_in_htmx_toggle_partial_for_non_staff(self):
|
|
|
|
|
Applet.objects.get_or_create(
|
|
|
|
|
slug="wallet-tokens",
|
|
|
|
|
defaults={"name": "Wallet Tokens", "grid_cols": 3, "grid_rows": 3, "context": "wallet"},
|
|
|
|
|
)
|
|
|
|
|
user = User.objects.create(email="reg2@test.io")
|
|
|
|
|
self.client.force_login(user)
|
|
|
|
|
response = self.client.post(
|
|
|
|
|
"/dashboard/wallet/toggle-applets",
|
|
|
|
|
{"applets": ["wallet-tokens"]},
|
|
|
|
|
HTTP_HX_REQUEST="true",
|
|
|
|
|
)
|
|
|
|
|
self.assertIsNone(response.context["pass_token"])
|
|
|
|
|
|
|
|
|
|
|
2026-03-11 00:58:24 -04:00
|
|
|
class WalletViewAppletContextTest(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create(email="walletctx@test.io")
|
|
|
|
|
Applet.objects.get_or_create(
|
|
|
|
|
slug="wallet-balances",
|
|
|
|
|
defaults={"name": "Wallet Balances", "grid_cols": 3, "grid_rows": 3, "context": "wallet"},
|
|
|
|
|
)
|
|
|
|
|
Applet.objects.get_or_create(
|
|
|
|
|
slug="wallet-tokens",
|
|
|
|
|
defaults={"name": "Wallet Tokens", "grid_cols": 3, "grid_rows": 3, "context": "wallet"},
|
|
|
|
|
)
|
|
|
|
|
Applet.objects.get_or_create(
|
|
|
|
|
slug="wallet-payment",
|
2026-03-24 14:13:44 -04:00
|
|
|
defaults={"name": "Payment Methods", "grid_cols": 6, "grid_rows": 3, "context": "wallet"},
|
2026-03-11 00:58:24 -04:00
|
|
|
)
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
|
|
|
|
|
def test_wallet_view_passes_applets_context(self):
|
|
|
|
|
response = self.client.get("/dashboard/wallet/")
|
|
|
|
|
slugs = [e["applet"].slug for e in response.context["applets"]]
|
|
|
|
|
self.assertIn("wallet-balances", slugs)
|
|
|
|
|
self.assertIn("wallet-tokens", slugs)
|
|
|
|
|
self.assertIn("wallet-payment", slugs)
|
|
|
|
|
|
|
|
|
|
def test_wallet_page_renders_applets_container(self):
|
|
|
|
|
response = self.client.get("/dashboard/wallet/")
|
|
|
|
|
parsed = lxml.html.fromstring(response.content)
|
|
|
|
|
[_] = parsed.cssselect("#id_wallet_applets_container")
|
|
|
|
|
|
|
|
|
|
def test_wallet_page_renders_gear_button(self):
|
|
|
|
|
response = self.client.get("/dashboard/wallet/")
|
|
|
|
|
parsed = lxml.html.fromstring(response.content)
|
|
|
|
|
[_] = parsed.cssselect(".gear-btn")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ToggleWalletAppletsTest(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create(email="wallettoggle@test.io")
|
|
|
|
|
self.balances = Applet.objects.get_or_create(
|
|
|
|
|
slug="wallet-balances",
|
|
|
|
|
defaults={"name": "Wallet Balances", "grid_cols": 3, "grid_rows": 3, "context": "wallet"},
|
|
|
|
|
)[0]
|
|
|
|
|
self.tokens = Applet.objects.get_or_create(
|
|
|
|
|
slug="wallet-tokens",
|
|
|
|
|
defaults={"name": "Wallet Tokens", "grid_cols": 3, "grid_rows": 3, "context": "wallet"},
|
|
|
|
|
)[0]
|
|
|
|
|
Applet.objects.get_or_create(
|
|
|
|
|
slug="wallet-payment",
|
2026-03-24 14:13:44 -04:00
|
|
|
defaults={"name": "Payment Methods", "grid_cols": 6, "grid_rows": 3, "context": "wallet"},
|
2026-03-11 00:58:24 -04:00
|
|
|
)
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
|
|
|
|
|
def test_toggle_requires_login(self):
|
|
|
|
|
self.client.logout()
|
|
|
|
|
response = self.client.post("/dashboard/wallet/toggle-applets", {})
|
|
|
|
|
self.assertRedirects(
|
|
|
|
|
response, "/?next=/dashboard/wallet/toggle-applets",
|
|
|
|
|
fetch_redirect_response=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_toggle_redirects_to_wallet(self):
|
|
|
|
|
response = self.client.post(
|
|
|
|
|
"/dashboard/wallet/toggle-applets", {"applets": ["wallet-balances"]}
|
|
|
|
|
)
|
|
|
|
|
self.assertRedirects(response, "/dashboard/wallet/", fetch_redirect_response=False)
|
|
|
|
|
|
|
|
|
|
def test_toggle_hides_unchecked_applet(self):
|
|
|
|
|
self.client.post(
|
|
|
|
|
"/dashboard/wallet/toggle-applets", {"applets": ["wallet-balances"]}
|
|
|
|
|
)
|
|
|
|
|
ua = UserApplet.objects.get(user=self.user, applet=self.tokens)
|
|
|
|
|
self.assertFalse(ua.visible)
|
|
|
|
|
|
|
|
|
|
def test_toggle_shows_checked_applet(self):
|
|
|
|
|
UserApplet.objects.create(user=self.user, applet=self.balances, visible=False)
|
|
|
|
|
self.client.post(
|
|
|
|
|
"/dashboard/wallet/toggle-applets", {"applets": ["wallet-balances"]}
|
|
|
|
|
)
|
|
|
|
|
ua = UserApplet.objects.get(user=self.user, applet=self.balances)
|
|
|
|
|
self.assertTrue(ua.visible)
|
|
|
|
|
|
|
|
|
|
def test_toggle_htmx_returns_container_partial(self):
|
|
|
|
|
response = self.client.post(
|
|
|
|
|
"/dashboard/wallet/toggle-applets",
|
|
|
|
|
{"applets": ["wallet-balances"]},
|
|
|
|
|
HTTP_HX_REQUEST="true",
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
self.assertContains(response, "id_wallet_applets_container")
|