Files
python-tdd/src/functional_tests/test_dash_wallet.py
Disco DeDisco 25f55f728a 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 8e476f5d28cf7b). **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

434 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from apps.applets.models import Applet
from apps.lyric.models import ShopItem, Token, User
class WalletDisplayTest(FunctionalTest):
def setUp(self):
super().setUp()
Applet.objects.get_or_create(slug="wallet", defaults={"name": "Wallet"})
for slug, name, cols, rows, order in [
("wallet-shop", "Shop", 12, 3, 10),
("wallet-balances", "Wallet Balances", 3, 3, 100),
("wallet-tokens", "Wallet Tokens", 3, 3, 100),
("wallet-payment", "Payment Methods", 6, 3, 100),
]:
Applet.objects.update_or_create(
slug=slug,
defaults={
"name": name, "grid_cols": cols, "grid_rows": rows,
"context": "wallet", "display_order": order,
},
)
# Seed the 3 starting ShopItems — migration `lyric/0009_seed_shop_items`
# populates these in fresh DBs, but FTs run on `TransactionTestCase`
# which wipes all data between tests (post-migrate signal pollution
# doesn't restore RunPython-seeded rows). Mirror the migration's
# locked row shapes here.
ShopItem.objects.update_or_create(
slug="tithe-1",
defaults={
"name": "Tithe Token", "description": "1 Tithe Token + 12 Writs",
"icon": "fa-piggy-bank", "badge_text": "",
"price_cents": 100, "granted_token_type": Token.TITHE,
"granted_count": 1, "granted_writs": 12,
"max_owned": None, "display_order": 10, "active": True,
},
)
ShopItem.objects.update_or_create(
slug="tithe-5",
defaults={
"name": "Tithe Bundle", "description": "5 Tithe Tokens + 60 Writs",
"icon": "fa-piggy-bank", "badge_text": "×5",
"price_cents": 400, "granted_token_type": Token.TITHE,
"granted_count": 5, "granted_writs": 60,
"max_owned": None, "display_order": 20, "active": True,
},
)
ShopItem.objects.update_or_create(
slug="band-1",
defaults={
"name": "Wristband", "description": "Admit All Entry",
"shoptalk": "Unlimited free entry (BYOB)",
"icon": "fa-ring", "badge_text": "",
"price_cents": 2000, "granted_token_type": Token.BAND,
"granted_count": 1, "granted_writs": 0,
"max_owned": 1, "display_order": 30, "active": True,
},
)
def test_new_user_wallet_shows_starting_balances(self):
# 1. Log in as new user
self.create_pre_authenticated_session("capman@test.io")
# 2. Navigate to dashboard
self.browser.get(self.live_server_url)
# 3. Find wallet applet summary card
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_wallet")
)
# 4. Click thru to full wallet page via manage link
self.browser.find_element(
By.CSS_SELECTOR, "#id_applet_wallet a.wallet-manage-link"
).click()
# 5. Assert user landed on wallet page
self.wait_for(
lambda: self.assertRegex(self.browser.current_url, r"/dashboard/wallet/$")
)
# 6. Assert writs balance shows 144 (complimentary bundle on signup)
self.wait_for(
lambda: self.assertEqual(
"144",
self.browser.find_element(By.ID, "id_writs_balance").text,
)
)
# 7. Assert esteem balance shows 0
self.assertEqual(
"0",
self.browser.find_element(By.ID, "id_esteem_balance").text,
)
# 8. Assert Coin-on-a-String token element present
coin = self.browser.find_element(By.ID, "id_coin_on_a_string")
# 9. Hover over it; assert tooltip appears w. name, entry text, 'no expiry'
ActionChains(self.browser).move_to_element(coin).perform()
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed()
)
)
coin_tooltip = self.browser.find_element(By.ID, "id_tooltip_portal").text
self.assertIn("Coin-on-a-String", coin_tooltip)
self.assertIn("Admit 1 Entry", coin_tooltip)
self.assertIn("no expiry", coin_tooltip)
# 10. Assert ×1 Free Token present (complimentary on signup)
free_token = self.browser.find_element(By.ID, "id_free_token")
# 11. Hover over it; assert tooltip shows name, entry text, expiry date
ActionChains(self.browser).move_to_element(free_token).perform()
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed()
)
)
free_tooltip = self.browser.find_element(By.ID, "id_tooltip_portal").text
self.assertIn("Free Token", free_tooltip)
self.assertIn("Admit 1 Entry", free_tooltip)
self.assertIn("Expires", free_tooltip)
def test_wallet_payment_section_renders(self):
# 1. Log in, navigate directly to wallet page
self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url + "/dashboard/wallet/")
# 2. Assert saved payment methods section present
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_payment_methods")
)
# 3. Assert the add-payment-method button is visible
self.browser.find_element(By.ID, "id_add_payment_method")
# 4. Assert Stripe Payment Element mount point exists
self.browser.find_element(By.ID, "id_stripe_payment_element")
def test_user_can_save_a_payment_method(self):
# 1. Log in, navigate to wallet page
self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url + "/dashboard/wallet/")
# 2. Click add-payment-method btn
self.browser.find_element(By.ID, "id_add_payment_method").click()
# 3. Wait for Stripe Payment Element iframe to appear inside mount point
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_stripe_payment_element iframe"
)
)
# 4. Switch into Stripe iframe to interact w. card fields
stripe_frame = self.browser.find_element(
By.CSS_SELECTOR, "#id_stripe_payment_element iframe"
)
self.browser.switch_to.frame(stripe_frame)
# 4a. Wait for card inputs to render inside iframe
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, 'input[placeholder="1234 1234 1234 1234"]'
)
)
# 5. Fill in Stripe test card details
self.browser.find_element(
By.CSS_SELECTOR, 'input[placeholder="1234 1234 1234 1234"]'
).send_keys("4242424242424242")
self.browser.find_element(
By.CSS_SELECTOR, 'input[placeholder="MM / YY"]'
).send_keys("12 / 26")
self.browser.find_element(
By.CSS_SELECTOR, 'input[placeholder="CVC"]'
).send_keys("424")
self.browser.find_element(
By.CSS_SELECTOR, 'input[placeholder="12345"]'
).send_keys("42424")
# 6. Return to main doc & submit form
self.browser.switch_to.default_content()
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_save_payment_method").get_attribute("hidden")
)
)
self.browser.find_element(By.ID, "id_save_payment_method").click()
# 7. Wait for saved card to appear in payment methods list
# Assert last 4 digits shown (Stripe confirmSetup + server round-trip can be slow)
self.wait_for_slow(
lambda: self.assertIn(
"4242",
self.browser.find_element(By.ID, "id_payment_methods").text,
)
)
def test_user_can_cancel_adding_payment_method(self):
# 1. Log in, navigate to wallet page
self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url + "/dashboard/wallet/")
# 2. Click Add Payment Method
self.wait_for_slow(
lambda: self.browser.find_element(By.ID, "id_add_payment_method")
).click()
# 3. Wait for Cancel button to appear (visible after setup-intent fetch returns)
self.wait_for_slow(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_cancel_payment_method").get_attribute("hidden")
)
)
# 3a. Assert applet expanded to 15 rows
rows = self.browser.execute_script(
"return document.getElementById('id_payment_methods')"
".style.getPropertyValue('--applet-rows').trim()"
)
self.assertEqual(rows, '15')
# 4. Click Cancel
self.browser.find_element(By.ID, "id_cancel_payment_method").click()
# 5. Assert Cancel + Save buttons are hidden again
self.wait_for_slow(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_cancel_payment_method").get_attribute("hidden")
)
)
self.assertTrue(
self.browser.find_element(By.ID, "id_save_payment_method").get_attribute("hidden")
)
# 6. Assert applet collapses back to 3 grid rows
rows = self.browser.execute_script(
"return document.getElementById('id_payment_methods')"
".style.getPropertyValue('--applet-rows').trim()"
)
self.assertEqual(rows, '3')
def test_wallet_tokens_applet_shows_all_owned_trinket_types(self):
"""Wallet Tokens applet renders every owned trinket-as-token type
independently — PASS (staff), BAND, COIN, CARTE — alongside FREE
+ TITHE. Pre-2026-05-22 the template had an if/elif chain that
only showed ONE of {PASS, BAND, COIN}; that hid BAND + COIN +
CARTE entirely from the wallet for any staff user holding a PASS,
even though all three are usable at the gate. Chunk 1 of the
Shop applet rollout drops that exclusivity so each trinket gets
its own tooltipped icon in the row."""
# 1. Build a staff user holding every trinket-as-token type
staff = User.objects.create(email="ledger@test.io", is_staff=True)
# post_save signal already created COIN (auto-equipped) + FREE + PASS;
# mint the rest manually so we exercise the full inventory render.
Token.objects.create(user=staff, token_type=Token.BAND)
Token.objects.create(user=staff, token_type=Token.CARTE)
Token.objects.create(user=staff, token_type=Token.TITHE)
# 2. Log in + land on wallet page
self.create_pre_authenticated_session("ledger@test.io")
self.browser.get(self.live_server_url + "/dashboard/wallet/")
# 3. Every trinket-as-token icon is present (no if/elif suppression)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pass_token"))
self.browser.find_element(By.ID, "id_band_token")
self.browser.find_element(By.ID, "id_coin_on_a_string")
self.browser.find_element(By.ID, "id_carte_token")
# 4. Consumable tokens still present
self.browser.find_element(By.ID, "id_free_token")
self.browser.find_element(By.ID, "id_tithe_token")
# 5. BAND tile carries its tooltip content in the DOM (the wallet's
# `initWalletTooltips` clones `.tt` innerHTML into the portal on
# hover — already exercised by the COIN/FREE hover paths in
# `test_new_user_wallet_shows_starting_balances`; here we just pin
# that the new BAND/CARTE template blocks server-render their full
# tooltip prose, which is the Chunk 1 contract).
band_tt = self.browser.find_element(By.CSS_SELECTOR, "#id_band_token .tt").get_attribute("innerHTML")
self.assertIn("Wristband", band_tt)
self.assertIn("Admit All Entry", band_tt)
self.assertIn("no expiry", band_tt)
# 6. CARTE tile carries its tooltip content too
carte_tt = self.browser.find_element(By.CSS_SELECTOR, "#id_carte_token .tt").get_attribute("innerHTML")
self.assertIn("Carte Blanche", carte_tt)
self.assertIn("Admit up to +6", carte_tt)
self.assertIn("no expiry", carte_tt)
def test_shop_applet_renders_seeded_items_with_icons_and_badges(self):
"""Chunk 4 of [[project-wallet-shop-expansion]] — the new Shop
applet renders the 3 seeded items (`tithe-1`, `tithe-5`, `band-1`)
as tooltipped tiles. The bundle (`tithe-5`) carries a `×5` badge;
the single-tithe + band tiles render without a badge.
Pinned visual contract: tile presence (id), icon class, badge text,
and price text inside the tooltip prose."""
self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url + "/dashboard/wallet/")
# 1. Shop applet is present
self.wait_for(lambda: self.browser.find_element(By.ID, "id_wallet_shop"))
# 2. Each of the 3 seeded items renders w. its own tile
tithe1 = self.browser.find_element(By.ID, "id_shop_tithe-1")
tithe5 = self.browser.find_element(By.ID, "id_shop_tithe-5")
band1 = self.browser.find_element(By.ID, "id_shop_band-1")
# 3. Icons are correct
self.assertIn("fa-piggy-bank", tithe1.find_element(By.CSS_SELECTOR, "i").get_attribute("class"))
self.assertIn("fa-piggy-bank", tithe5.find_element(By.CSS_SELECTOR, "i").get_attribute("class"))
self.assertIn("fa-ring", band1.find_element(By.CSS_SELECTOR, "i").get_attribute("class"))
# 4. Bundle carries the ×5 badge; singles don't
self.assertIn("×5", tithe5.find_element(By.CSS_SELECTOR, ".shop-badge").text)
self.assertEqual(tithe1.find_elements(By.CSS_SELECTOR, ".shop-badge"), [])
self.assertEqual(band1.find_elements(By.CSS_SELECTOR, ".shop-badge"), [])
# 5. Tooltip prose includes name + price (read .tt innerHTML directly
# — hover→portal cloning is already exercised by the existing
# COIN/FREE hover tests).
tithe1_tt = tithe1.find_element(By.CSS_SELECTOR, ".tt").get_attribute("innerHTML")
self.assertIn("Tithe Token", tithe1_tt)
self.assertIn("$1", tithe1_tt)
band1_tt = band1.find_element(By.CSS_SELECTOR, ".tt").get_attribute("innerHTML")
self.assertIn("Wristband", band1_tt)
self.assertIn("$20", band1_tt)
def test_shop_buy_click_opens_guard_portal_with_purchase_prompt(self):
"""BUY ITEM click opens `#id_guard_portal` w. an 'Are you sure?'-style
prompt naming the item + price. Click NVM dismisses; click OK
triggers the Stripe.js dance (mocked out / deferred to a follow-up
FT)."""
self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url + "/dashboard/wallet/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_wallet_shop"))
# 1. Find BUY ITEM btn inside the tithe-1 tile's microtooltip
buy_btn = self.browser.find_element(
By.CSS_SELECTOR, "#id_shop_tithe-1 .tt-buy-btn"
)
# 2. Click it via JS (microtooltip lives in the portal layer + may
# not be hover-visible during Selenium's strict-target check)
self.browser.execute_script("arguments[0].click();", buy_btn)
# 3. Guard portal opens w. a prompt mentioning Tithe + $1
portal = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_guard_portal")
)
self.wait_for(
lambda: self.assertIn("active", portal.get_attribute("class"))
)
msg = portal.find_element(By.CSS_SELECTOR, ".guard-message").text
self.assertIn("Tithe", msg)
self.assertIn("$1", msg)
# 4. NVM dismisses
portal.find_element(By.CSS_SELECTOR, ".guard-no").click()
self.wait_for(
lambda: self.assertNotIn("active", portal.get_attribute("class"))
)
def test_shop_buy_guard_portal_pins_item_tooltip(self):
"""While the BUY-ITEM guard portal is open, the item's main +
mini tooltip stay pinned — they don't dismiss when the cursor
leaves the BUY btn area to reach the guard's OK / NVM. Fixes
the orphan-prompt UX where "Buy {item} for ${price}?" floated
on its own w. no visual referent. Pinning is released on
either confirm OR dismiss; both schedule the normal hide."""
import time
self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url + "/dashboard/wallet/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_wallet_shop"))
# 1. Programmatically trigger the tile-enter → both portals active.
self.browser.execute_script(
"document.getElementById('id_shop_tithe-5').dispatchEvent("
"new MouseEvent('mouseenter', {bubbles: true}));"
)
self.wait_for(
lambda: self.assertIn(
"active",
self.browser.find_element(By.ID, "id_tooltip_portal").get_attribute("class"),
)
)
self.assertIn(
"active",
self.browser.find_element(By.ID, "id_mini_tooltip_portal").get_attribute("class"),
)
# 2. Click the cloned BUY btn inside the mini portal — opens guard.
self.browser.execute_script(
"document.querySelector('#id_mini_tooltip_portal .tt-buy-btn').click();"
)
guard = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_guard_portal")
)
self.wait_for(
lambda: self.assertIn("active", guard.get_attribute("class"))
)
# 3. Simulate cursor leaving both portals to reach the guard btns.
self.browser.execute_script("""
document.getElementById('id_mini_tooltip_portal').dispatchEvent(
new MouseEvent('mouseleave', {bubbles: true}));
document.getElementById('id_tooltip_portal').dispatchEvent(
new MouseEvent('mouseleave', {bubbles: true}));
""")
# 4. Wait longer than the wallet.js hide delay (200ms) + buffer.
time.sleep(0.4)
# 5. Both portals are STILL active — pinned by the open guard.
self.assertIn(
"active",
self.browser.find_element(By.ID, "id_tooltip_portal").get_attribute("class"),
)
self.assertIn(
"active",
self.browser.find_element(By.ID, "id_mini_tooltip_portal").get_attribute("class"),
)
# 6. Click NVM — guard dismisses, pin releases.
guard.find_element(By.CSS_SELECTOR, ".guard-no").click()
self.wait_for(
lambda: self.assertNotIn("active", guard.get_attribute("class"))
)
# 7. After unpin + hide delay, portals fade out.
time.sleep(0.4)
self.assertNotIn(
"active",
self.browser.find_element(By.ID, "id_tooltip_portal").get_attribute("class"),
)
self.assertNotIn(
"active",
self.browser.find_element(By.ID, "id_mini_tooltip_portal").get_attribute("class"),
)
def test_shop_band_already_owned_shows_disabled_buy_btn(self):
"""User who already owns 1 BAND (`max_owned=1`) sees the band-1
tile w. its BUY btn disabled + an 'Already owned' microtooltip
— visible-but-unbuyable per the decision in [[project-wallet-
shop-expansion]]."""
owner = User.objects.create(email="bander@test.io")
Token.objects.create(user=owner, token_type=Token.BAND)
self.create_pre_authenticated_session("bander@test.io")
self.browser.get(self.live_server_url + "/dashboard/wallet/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_wallet_shop"))
band_tile = self.browser.find_element(By.ID, "id_shop_band-1")
# Capped item — NO buy btn at all (parity w. Game Kit's status-
# only "Equipped" / "In-Use: X" pills, which never pair status
# text w. a disabled action btn).
self.assertEqual(band_tile.find_elements(By.CSS_SELECTOR, ".tt-buy-btn"), [])
# 'Already owned' microtext lives in the `.tt-micro` sibling-of-.tt
# (cloned into `#id_mini_tooltip_portal` on hover by wallet.js).
micro_html = band_tile.find_element(By.CSS_SELECTOR, ".tt-micro").get_attribute("innerHTML")
self.assertIn("Already owned", micro_html)
# Legacy `test_user_can_purchase_tithe_token_bundle` FT (asserting
# `#id_tithe_token_shop` inside Balances) was removed in Chunk 5 of
# [[project-wallet-shop-expansion]] — the tithe purchase surface
# moved to the dedicated Shop applet. Coverage now lives in:
# - `test_shop_applet_renders_seeded_items_with_icons_and_badges`
# (tile + icon + badge + price)
# - `test_shop_buy_click_opens_guard_portal_with_purchase_prompt`
# (BUY → guard portal → NVM dismisses)
# - `test_shop_band_already_owned_shows_disabled_buy_btn`
# (max_owned cap renders BUY as `.btn-disabled` w. microtext)