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>
This commit is contained in:
@@ -10,15 +10,18 @@ class WalletDisplayTest(FunctionalTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
Applet.objects.get_or_create(slug="wallet", defaults={"name": "Wallet"})
|
||||
for slug, name, cols, rows in [
|
||||
("wallet-balances", "Wallet Balances", 3, 3),
|
||||
("wallet-tokens", "Wallet Tokens", 3, 3),
|
||||
("wallet-payment", "Payment Methods", 6, 3),
|
||||
("wallet-shop", "Shop", 12, 3),
|
||||
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.get_or_create(
|
||||
Applet.objects.update_or_create(
|
||||
slug=slug,
|
||||
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "wallet"},
|
||||
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`
|
||||
@@ -28,20 +31,20 @@ class WalletDisplayTest(FunctionalTest):
|
||||
ShopItem.objects.update_or_create(
|
||||
slug="tithe-1",
|
||||
defaults={
|
||||
"name": "Tithe Token", "description": "1 Tithe + 144 Writs",
|
||||
"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": 144,
|
||||
"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 + 750 Writs",
|
||||
"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": 750,
|
||||
"granted_count": 5, "granted_writs": 60,
|
||||
"max_owned": None, "display_order": 20, "active": True,
|
||||
},
|
||||
)
|
||||
@@ -49,6 +52,7 @@ class WalletDisplayTest(FunctionalTest):
|
||||
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,
|
||||
@@ -325,6 +329,77 @@ class WalletDisplayTest(FunctionalTest):
|
||||
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
|
||||
@@ -336,16 +411,14 @@ class WalletDisplayTest(FunctionalTest):
|
||||
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")
|
||||
# BUY btn rendered as `.btn-disabled` w. × glyph (parity w. game-
|
||||
# kit's disabled DON/DOFF buttons). Read via `textContent` because
|
||||
# `.tt` is `display: none` by default + Selenium's `.text` returns
|
||||
# empty for hidden subtrees.
|
||||
buy_btn = band_tile.find_element(By.CSS_SELECTOR, ".tt-buy-btn")
|
||||
self.assertIn("btn-disabled", buy_btn.get_attribute("class"))
|
||||
self.assertEqual(buy_btn.get_attribute("textContent").strip(), "×")
|
||||
# Microtooltip swap signals why it's disabled
|
||||
tt_html = band_tile.find_element(By.CSS_SELECTOR, ".tt").get_attribute("innerHTML")
|
||||
self.assertIn("Already owned", tt_html)
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user