bud panels duplicate-add guard: server-side already_present flag + client-side error Brief w. FYI flash highlight on the existing entry — for each of the three #id_bud_btn panels (My Buds / post-share / gatekeeper-invite), the JSON response from add_bud / share_post / invite_gamer now carries {already_present, recipient_display, recipient_user_id}; bud-btn.js branches on already_present → calls new Brief.showDuplicateBanner({display_name, target_selector}) instead of the normal onSuccess append; banner title reads @<username> is already present, NVM dismisses, FYI dismisses AND eases in the .bud-duplicate-flash class (color: var(--terUser); text-shadow: 0 0 .5em var(--ninUser); transition: 600ms) onto the existing element (.bud-entry .bud-name / .post-recipient[data-user-id=…] / .gate-slot.filled[data-user-id=…]); gatekeeper "already present" = recipient is either GateSlot.FILLED + gamer OR has TableSeat OR has a pending RoomInvite (highlight target only set when seated — pending invites have no visible slot); .post-recipient chips + .gate-slot.filled cells gain data-user-id so the FYI selector can find them; my_buds.html now loads note.js via the {% block scripts %} pattern (Brief module is required by the duplicate banner path); bonus: latent test_jasmine.py bug fixed — "0 failures" in result.text matched "10 failures" / "20 failures" / etc, silently passing up to 99 failed specs; replaced w. re.search(r"(?<!\d)0 failures\b", …) (caught my new red specs, would've caught any prior Jasmine regression); 18 new ITs + 10 new Jasmine specs + 3 new FTs (one per panel) — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-12 16:40:15 -04:00
parent 264ed5968e
commit be919c7aff
19 changed files with 738 additions and 38 deletions

View File

@@ -379,3 +379,49 @@ class PostHtmlAperturePageClassTest(FunctionalTest):
"page-billboard" in cls or "page-billpost" in cls,
f"my_posts.html body class missing aperture marker: {cls!r}",
)
class BudBtnDuplicateShareErrorTest(FunctionalTest):
"""Re-sharing a post w. someone already in post.shared_with triggers
the error Brief titled `@<username> is already present`. FYI on the
Brief dismisses + adds .bud-duplicate-flash to the existing
.post-recipient[data-user-id=…] chip."""
def setUp(self):
super().setUp()
self.sharer = User.objects.create(email="bud@test.io", username="bud")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.post = _seed_a_post(self.sharer)
self.post.shared_with.add(self.alice)
self.create_pre_authenticated_session("bud@test.io")
self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
def test_duplicate_share_shows_error_brief_and_fyi_flashes_chip(self):
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
recipient.send_keys("alice@test.io")
self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm").click()
title = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__title"
))
self.assertEqual(title.text, "@alice is already present")
# Existing chip carries data-user-id so the FYI highlight can find it
chip = self.browser.find_element(
By.CSS_SELECTOR, f".post-recipient[data-user-id='{self.alice.id}']"
)
self.assertNotIn("bud-duplicate-flash", chip.get_attribute("class") or "")
# FYI dismisses + applies flash class
self.browser.find_element(
By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__fyi"
).click()
self.wait_for(lambda: self.assertEqual(
self.browser.find_elements(By.CSS_SELECTOR, ".note-banner--duplicate"),
[],
))
self.wait_for(lambda: self.assertIn(
"bud-duplicate-flash", chip.get_attribute("class") or ""
))

View File

@@ -10,7 +10,7 @@ epic:invite_gamer w. Accept: application/json — server returns
from selenium.webdriver.common.by import By
from apps.billboard.models import Brief
from apps.epic.models import Room, RoomInvite
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat
from apps.lyric.models import User
from .base import FunctionalTest
@@ -114,3 +114,64 @@ class GatekeeperBudBtnAsyncInviteTest(FunctionalTest):
self.wait_for(lambda: self.assertIn(
self.alice, list(self.owner.buds.all())
))
class GatekeeperBudBtnDuplicateInviteErrorTest(FunctionalTest):
"""Re-inviting a recipient already seated in the room triggers the
error Brief titled `@<username> is already present`. FYI on the Brief
dismisses + adds .bud-duplicate-flash to the existing
.gate-slot.filled[data-user-id=…] element. Pending-but-unseated
duplicates also surface the Brief but FYI has no slot to highlight."""
def setUp(self):
super().setUp()
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.room = Room.objects.create(name="Dup Room", owner=self.owner)
# Seat alice via a GateSlot — _gate_positions renders .gate-slot.filled
# cells from GateSlot records (TableSeat spins up later at SIG SELECT),
# so the duplicate-highlight target lives there during gatekeeper phase.
GateSlot.objects.create(
room=self.room, gamer=self.alice, slot_number=1,
status=GateSlot.FILLED,
)
self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/"
self.create_pre_authenticated_session("owner@test.io")
self.browser.get(self.room_url)
def test_duplicate_invite_shows_error_brief_and_fyi_flashes_slot(self):
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
recipient.send_keys("alice@test.io")
self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm").click()
title = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__title"
))
self.assertEqual(title.text, "@alice is already present")
# No new RoomInvite or Brief persisted server-side on duplicate
self.assertFalse(RoomInvite.objects.filter(
room=self.room, invitee_email="alice@test.io",
).exists())
self.assertEqual(
Brief.objects.filter(owner=self.owner, kind=Brief.KIND_GAME_INVITE).count(),
0,
)
slot = self.browser.find_element(
By.CSS_SELECTOR, f".gate-slot.filled[data-user-id='{self.alice.id}']"
)
self.assertNotIn("bud-duplicate-flash", slot.get_attribute("class") or "")
self.browser.find_element(
By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__fyi"
).click()
self.wait_for(lambda: self.assertEqual(
self.browser.find_elements(By.CSS_SELECTOR, ".note-banner--duplicate"),
[],
))
self.wait_for(lambda: self.assertIn(
"bud-duplicate-flash", slot.get_attribute("class") or ""
))

View File

@@ -1,3 +1,5 @@
import re
from selenium.webdriver.common.by import By
from .base import FunctionalTest
@@ -8,7 +10,10 @@ class JasmineTest(FunctionalTest):
def check_results():
result = self.browser.find_element(By.CSS_SELECTOR, ".jasmine-overall-result")
if "0 failures" not in result.text:
# Word-boundary anchor — Jasmine 6 reports as "N specs, X failures".
# Plain `"0 failures" in text` matches "10 failures", "20 failures",
# etc., letting up to 99 failed specs slip past as green.
if not re.search(r"(?<!\d)0 failures\b", result.text):
failures = self.browser.find_elements(
By.CSS_SELECTOR, ".jasmine-failed .jasmine-description"
)

View File

@@ -88,3 +88,44 @@ class MyBudsPageTest(FunctionalTest):
# No bud entries (the empty-state row has its own --empty class)
entries = self.browser.find_elements(By.CSS_SELECTOR, ".bud-entry")
self.assertEqual(len(entries), 0)
def test_re_add_existing_bud_shows_already_present_brief_and_fyi_flashes_bud_name(self):
"""Re-adding an existing bud: server returns already_present=true;
client renders the error Brief titled `@alice is already present`;
clicking FYI dismisses the Brief AND adds .bud-duplicate-flash to
the existing .bud-name cell."""
self.gamer.buds.add(self.alice)
self.browser.get(self.live_server_url + "/billboard/my-buds/")
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
recipient.send_keys("alice@test.io")
self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm").click()
# Error Brief appears w. the duplicate title
title = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__title"
))
self.assertEqual(title.text, "@alice is already present")
# Only one .bud-entry — no second alice row appended
self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, ".bud-entry")),
1,
)
# Pre-flash: the .bud-name carries no flash class yet
bud_name = self.browser.find_element(
By.CSS_SELECTOR, f".bud-entry[data-bud-id='{self.alice.id}'] .bud-name"
)
self.assertNotIn("bud-duplicate-flash", bud_name.get_attribute("class") or "")
# Click FYI → Brief dismisses AND .bud-name gets the flash class
self.browser.find_element(By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__fyi").click()
self.wait_for(lambda: self.assertEqual(
self.browser.find_elements(By.CSS_SELECTOR, ".note-banner--duplicate"),
[],
))
self.wait_for(lambda: self.assertIn(
"bud-duplicate-flash", bud_name.get_attribute("class") or ""
))