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

@@ -4,12 +4,23 @@
// • _bud_add_panel.html — My Buds add (POSTs to billboard:add_bud) // • _bud_add_panel.html — My Buds add (POSTs to billboard:add_bud)
// //
// Owns: csrf cookie read, open/close + .bud-open html-class, button click, // Owns: csrf cookie read, open/close + .bud-open html-class, button click,
// Escape, click-outside, Enter-in-input, OK POST + JSON routing. // Escape, click-outside, Enter-in-input, OK POST + JSON routing, and the
// `data.already_present` duplicate-guard branch (error Brief instead of
// the normal onSuccess append).
// //
// Each caller drives it with `bindBudBtn({submitUrl, autocompleteUrl?, onSuccess})`. // Each caller drives it with:
// `onSuccess(data)` does the panel-specific DOM updates (line append, chip // bindBudBtn({
// append, bud-entry append, Brief banner — whatever the response carries). // submitUrl,
// _close({clear: true}) fires automatically on a successful response. // autocompleteUrl?,
// onSuccess(data), // success path: line/chip/entry/Brief
// duplicateTargetSelector?(data), // selector for .bud-duplicate-flash
// })
//
// `onSuccess(data)` does the panel-specific DOM updates on the new-row path.
// `duplicateTargetSelector(data)` returns a CSS selector for the existing
// element to highlight when the FYI button on the error Brief is clicked
// (.bud-name / .post-recipient / .gate-slot.filled — varies by page).
// _close({clear: true}) fires automatically on every successful response.
// //
// `autocompleteUrl` enables bud-autocomplete on the input (post-share + // `autocompleteUrl` enables bud-autocomplete on the input (post-share +
// gatekeeper panels) by binding bud-autocomplete.js to #id_bud_suggestions. // gatekeeper panels) by binding bud-autocomplete.js to #id_bud_suggestions.
@@ -87,7 +98,21 @@
}) })
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); }) .then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (data) { .then(function (data) {
if (typeof opts.onSuccess === 'function') opts.onSuccess(data); if (data && data.already_present) {
// Skip onSuccess — there's nothing to append. Show the
// error Brief instead. target_selector resolves at call
// time from the caller's per-page callback.
if (window.Brief) {
var sel = (typeof opts.duplicateTargetSelector === 'function')
? opts.duplicateTargetSelector(data) : null;
Brief.showDuplicateBanner({
display_name: data.recipient_display,
target_selector: sel,
});
}
} else if (typeof opts.onSuccess === 'function') {
opts.onSuccess(data);
}
_close({ clear: true }); _close({ clear: true });
}) })
.catch(function () { .catch(function () {

View File

@@ -239,3 +239,84 @@ class SharePostImplicitAutoAddTest(TestCase):
"""Privacy: unregistered email doesn't touch the buds graph.""" """Privacy: unregistered email doesn't touch the buds graph."""
self._share("ghost@test.io") self._share("ghost@test.io")
self.assertEqual(self.sharer.buds.count(), 0) self.assertEqual(self.sharer.buds.count(), 0)
class AddBudAlreadyPresentTest(TestCase):
"""Duplicate-add guard: add_bud's JSON response distinguishes "newly
added" from "already a bud" so the bud-btn JS can render an error
Brief titled `@<username> is already present` instead of the normal
bud-entry append. `recipient_user_id` is the highlight target id that
the Brief FYI button toggles `.bud-duplicate-flash` onto."""
def setUp(self):
self.user = User.objects.create(email="me@test.io", username="me")
self.client.force_login(self.user)
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.user.buds.add(self.alice)
def _add(self, recipient):
return self.client.post(
reverse("billboard:add_bud"), {"recipient": recipient},
)
def test_already_present_true_when_candidate_is_already_a_bud(self):
self.assertTrue(self._add("alice@test.io").json()["already_present"])
def test_already_present_false_for_new_bud(self):
User.objects.create(email="bob@test.io", username="bob")
self.assertFalse(self._add("bob@test.io").json()["already_present"])
def test_already_present_false_for_unregistered_email(self):
self.assertFalse(self._add("ghost@test.io").json()["already_present"])
def test_recipient_display_carries_username_on_duplicate(self):
self.assertEqual(self._add("alice@test.io").json()["recipient_display"], "alice")
def test_recipient_user_id_carries_alice_id_on_duplicate(self):
self.assertEqual(
self._add("alice@test.io").json()["recipient_user_id"],
str(self.alice.id),
)
def test_username_typed_recipient_also_detects_duplicate(self):
"""A user typing 'alice' (no @) resolves the same way as the email."""
self.assertTrue(self._add("alice").json()["already_present"])
class SharePostAlreadyPresentTest(TestCase):
"""Duplicate-share guard mirrors AddBudAlreadyPresentTest — when the
recipient is already in post.shared_with, response carries
already_present + recipient_user_id for the .post-recipient highlight."""
def setUp(self):
from apps.billboard.models import Post
self.sharer = User.objects.create(email="sharer@test.io", username="sharer")
self.client.force_login(self.sharer)
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.post = Post.objects.create(owner=self.sharer)
self.post.shared_with.add(self.alice)
def _share(self, recipient):
return self.client.post(
reverse("billboard:share_post", args=[self.post.id]),
data={"recipient": recipient},
HTTP_ACCEPT="application/json",
)
def test_already_present_true_when_recipient_in_shared_with(self):
self.assertTrue(self._share("alice@test.io").json()["already_present"])
def test_already_present_false_for_new_recipient(self):
User.objects.create(email="bob@test.io", username="bob")
self.assertFalse(self._share("bob@test.io").json()["already_present"])
def test_recipient_user_id_present_on_duplicate(self):
self.assertEqual(
self._share("alice@test.io").json()["recipient_user_id"],
str(self.alice.id),
)
def test_recipient_display_already_present_on_duplicate(self):
"""`recipient_display` already exists on the success path; on
duplicate the same field must carry the matched user's handle."""
self.assertEqual(self._share("alice@test.io").json()["recipient_display"], "alice")

View File

@@ -392,12 +392,16 @@ def share_post(request, post_id):
# list exposes; doesn't widen the privacy surface beyond what the # list exposes; doesn't widen the privacy surface beyond what the
# post detail page already shows publicly. # post detail page already shows publicly.
recipient_display = None recipient_display = None
recipient_user_id = None
if recipient is not None: if recipient is not None:
recipient_display = recipient.username or recipient.email recipient_display = recipient.username or recipient.email
recipient_user_id = str(recipient.id)
return JsonResponse({ return JsonResponse({
"brief": brief.to_banner_dict() if brief is not None else None, "brief": brief.to_banner_dict() if brief is not None else None,
"line_text": line_text, "line_text": line_text,
"recipient_display": recipient_display, "recipient_display": recipient_display,
"recipient_user_id": recipient_user_id,
"already_present": is_reshare,
}) })
messages.success(request, "An invite has been sent if that address is registered.") messages.success(request, "An invite has been sent if that address is registered.")
@@ -446,16 +450,28 @@ def add_bud(request):
candidate = _resolve_recipient(request.POST.get("recipient")) candidate = _resolve_recipient(request.POST.get("recipient"))
bud = None bud = None
already_present = False
recipient_display = None
recipient_user_id = None
if candidate is not None and candidate != request.user: if candidate is not None and candidate != request.user:
if candidate not in request.user.buds.all(): already_present = candidate in request.user.buds.all()
if not already_present:
request.user.buds.add(candidate) request.user.buds.add(candidate)
display = candidate.username or candidate.email
bud = { bud = {
"id": str(candidate.id), "id": str(candidate.id),
"username": candidate.username or candidate.email, "username": display,
"email": candidate.email, "email": candidate.email,
} }
recipient_display = display
recipient_user_id = str(candidate.id)
return JsonResponse({"bud": bud}) return JsonResponse({
"bud": bud,
"already_present": already_present,
"recipient_display": recipient_display,
"recipient_user_id": recipient_user_id,
})
@login_required(login_url="/") @login_required(login_url="/")

View File

@@ -69,13 +69,54 @@ const Brief = (() => {
showBanner(data && data.brief); showBanner(data && data.brief);
} }
// Error-variant banner for "@<username> is already present". Distinct
// from showBanner because it's purely client-side (no Brief DB row);
// title carries the recipient handle; there's no date/square/post_url;
// FYI is a <button> (no navigation) that toggles `.bud-duplicate-flash`
// onto a caller-supplied target element + dismisses. NVM just dismisses.
function showDuplicateBanner(opts) {
if (!opts || !opts.display_name) return;
var banner = document.createElement('div');
banner.className = 'note-banner note-banner--duplicate';
banner.innerHTML =
'<div class="note-banner__body">' +
'<p class="note-banner__title">@' + _esc(opts.display_name) + ' is already present</p>' +
'</div>' +
'<button type="button" class="btn btn-cancel note-banner__nvm">NVM</button>' +
'<button type="button" class="btn btn-info note-banner__fyi">FYI</button>';
banner.querySelector('.note-banner__nvm').addEventListener('click', function () {
banner.remove();
});
banner.querySelector('.note-banner__fyi').addEventListener('click', function () {
if (opts.target_selector) {
var target = document.querySelector(opts.target_selector);
if (target) target.classList.add('bud-duplicate-flash');
}
banner.remove();
});
var anchor = document.getElementById('id_brief_banner_anchor')
|| document.querySelector('h2');
if (anchor && anchor.parentNode) {
anchor.parentNode.insertBefore(banner, anchor.nextSibling);
} else {
document.body.insertBefore(banner, document.body.firstChild);
}
}
function _esc(str) { function _esc(str) {
var d = document.createElement('div'); var d = document.createElement('div');
d.textContent = str || ''; d.textContent = str || '';
return d.innerHTML; return d.innerHTML;
} }
return { showBanner: showBanner, handleSaveResponse: handleSaveResponse }; return {
showBanner: showBanner,
showDuplicateBanner: showDuplicateBanner,
handleSaveResponse: handleSaveResponse,
};
})(); })();
// Backwards-compat shim — to be removed once the codebase uniformly uses Brief. // Backwards-compat shim — to be removed once the codebase uniformly uses Brief.

View File

@@ -141,3 +141,88 @@ class InviteGamerLegacyFormTest(TestCase):
self.assertTrue(RoomInvite.objects.filter( self.assertTrue(RoomInvite.objects.filter(
room=self.room, invitee_email="alice@test.io", room=self.room, invitee_email="alice@test.io",
).exists()) ).exists())
class InviteGamerAlreadyPresentTest(TestCase):
"""Duplicate-invite guard: a gatekeeper invite gets `already_present:
true` in its JSON response when the recipient is either (a) already
seated in the room or (b) has a pending RoomInvite. `recipient_user_id`
is set only when seated — pending invitees have no visible
`.gate-slot.filled` DOM element to highlight, so the FYI button just
dismisses without easing in the flash class."""
def setUp(self):
from apps.epic.models import GateSlot, TableSeat
self.GateSlot = GateSlot
self.TableSeat = TableSeat
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.client.force_login(self.owner)
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.room = Room.objects.create(name="Bingo", owner=self.owner)
def _invite(self, recipient):
return self.client.post(
reverse("epic:invite_gamer", args=[self.room.id]),
data={"recipient": recipient},
HTTP_ACCEPT="application/json",
)
def test_already_present_true_when_recipient_is_seated_via_gate_slot(self):
"""Gatekeeper phase: 'seated' means a GateSlot.FILLED w. gamer set."""
self.GateSlot.objects.create(
room=self.room, gamer=self.alice, slot_number=1,
status=self.GateSlot.FILLED,
)
self.assertTrue(self._invite("alice@test.io").json()["already_present"])
def test_already_present_true_when_recipient_has_table_seat(self):
"""Post-gatekeeper phase: TableSeat exists once slots transition."""
self.TableSeat.objects.create(room=self.room, gamer=self.alice, slot_number=1)
self.assertTrue(self._invite("alice@test.io").json()["already_present"])
def test_recipient_user_id_carries_alice_id_when_seated(self):
self.TableSeat.objects.create(room=self.room, gamer=self.alice, slot_number=1)
body = self._invite("alice@test.io").json()
self.assertEqual(body["recipient_user_id"], str(self.alice.id))
def test_already_present_true_when_recipient_has_pending_room_invite(self):
RoomInvite.objects.create(
room=self.room, inviter=self.owner,
invitee_email="alice@test.io", status=RoomInvite.PENDING,
)
self.assertTrue(self._invite("alice@test.io").json()["already_present"])
def test_recipient_user_id_null_when_only_invited_not_seated(self):
"""Pending invites have no visible slot element to highlight."""
RoomInvite.objects.create(
room=self.room, inviter=self.owner,
invitee_email="alice@test.io", status=RoomInvite.PENDING,
)
self.assertIsNone(self._invite("alice@test.io").json()["recipient_user_id"])
def test_already_present_false_for_brand_new_recipient(self):
self.assertFalse(self._invite("alice@test.io").json()["already_present"])
def test_brief_is_null_on_duplicate(self):
"""No new Brief on duplicate — the client renders the error Brief
from the already_present flag instead."""
self.TableSeat.objects.create(room=self.room, gamer=self.alice, slot_number=1)
self.assertIsNone(self._invite("alice@test.io").json()["brief"])
def test_no_new_room_invite_on_duplicate(self):
RoomInvite.objects.create(
room=self.room, inviter=self.owner,
invitee_email="alice@test.io", status=RoomInvite.PENDING,
)
self._invite("alice@test.io")
self.assertEqual(
RoomInvite.objects.filter(room=self.room, invitee_email="alice@test.io").count(),
1,
)
def test_recipient_display_carries_username_on_duplicate(self):
self.TableSeat.objects.create(room=self.room, gamer=self.alice, slot_number=1)
self.assertEqual(
self._invite("alice@test.io").json()["recipient_display"],
"alice",
)

View File

@@ -699,21 +699,43 @@ def invite_gamer(request, room_id):
# username-typed invite doesn't store the raw username as if it were # username-typed invite doesn't store the raw username as if it were
# an email); falls back to the raw input for unregistered addresses. # an email); falls back to the raw input for unregistered addresses.
invitee_email = candidate.email if candidate else raw invitee_email = candidate.email if candidate else raw
RoomInvite.objects.get_or_create(
# Duplicate-invite guard: "already present" = recipient is either
# already seated in the room OR has a (pending/accepted) RoomInvite.
# During gatekeeper phase the visible `.gate-slot.filled` cells are
# GateSlot-driven (TableSeats spin up later at SIG SELECT), so check
# both — GateSlot.FILLED catches the in-phase case, TableSeat catches
# the post-phase case. Seated recipients carry recipient_user_id so
# the client can find the .gate-slot.filled[data-user-id="X"]
# highlight target; pending invitees have no visible slot, so
# recipient_user_id stays null.
already_seated = candidate is not None and (
GateSlot.objects.filter(
room=room, gamer=candidate, status=GateSlot.FILLED,
).exists()
or TableSeat.objects.filter(room=room, gamer=candidate).exists()
)
already_invited = RoomInvite.objects.filter(
room=room, invitee_email=invitee_email,
).exists()
already_present = already_seated or already_invited
brief = None
if not already_present:
RoomInvite.objects.create(
room=room, room=room,
inviter=request.user, inviter=request.user,
invitee_email=invitee_email, invitee_email=invitee_email,
defaults={"status": RoomInvite.PENDING}, status=RoomInvite.PENDING,
) )
# Buds graph: symmetric auto-add on registered recipients (mirrors # Buds graph: symmetric auto-add on registered recipients (mirrors
# share_post). Idempotent on M2M; no-op on unregistered recipients. # share_post). Idempotent on M2M; no-op on unregistered recipients.
if candidate is not None: if candidate is not None:
request.user.buds.add(candidate) request.user.buds.add(candidate)
candidate.buds.add(request.user) candidate.buds.add(request.user)
# Brief: confirmation banner for the inviter. Brief.post stays
# Brief: confirmation banner for the inviter. Brief.post stays null; # null; banner FYI navigates to the room's gatekeeper page via
# banner FYI navigates to the room's gatekeeper page via Brief.room. # Brief.room.
brief = Brief.objects.create( brief = Brief.objects.create(
owner=request.user, owner=request.user,
post=None, post=None,
@@ -722,13 +744,17 @@ def invite_gamer(request, room_id):
title="Invite sent", title="Invite sent",
) )
recipient_user_id = str(candidate.id) if already_seated else None
if is_ajax: if is_ajax:
recipient_display = None recipient_display = None
if candidate is not None: if candidate is not None:
recipient_display = candidate.username or candidate.email recipient_display = candidate.username or candidate.email
return JsonResponse({ return JsonResponse({
"brief": brief.to_banner_dict(), "brief": brief.to_banner_dict() if brief is not None else None,
"recipient_display": recipient_display, "recipient_display": recipient_display,
"recipient_user_id": recipient_user_id,
"already_present": already_present,
}) })
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:gatekeeper", room_id=room_id)

View File

@@ -379,3 +379,49 @@ class PostHtmlAperturePageClassTest(FunctionalTest):
"page-billboard" in cls or "page-billpost" in cls, "page-billboard" in cls or "page-billpost" in cls,
f"my_posts.html body class missing aperture marker: {cls!r}", 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 selenium.webdriver.common.by import By
from apps.billboard.models import Brief 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 apps.lyric.models import User
from .base import FunctionalTest from .base import FunctionalTest
@@ -114,3 +114,64 @@ class GatekeeperBudBtnAsyncInviteTest(FunctionalTest):
self.wait_for(lambda: self.assertIn( self.wait_for(lambda: self.assertIn(
self.alice, list(self.owner.buds.all()) 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 selenium.webdriver.common.by import By
from .base import FunctionalTest from .base import FunctionalTest
@@ -8,7 +10,10 @@ class JasmineTest(FunctionalTest):
def check_results(): def check_results():
result = self.browser.find_element(By.CSS_SELECTOR, ".jasmine-overall-result") 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( failures = self.browser.find_elements(
By.CSS_SELECTOR, ".jasmine-failed .jasmine-description" 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) # No bud entries (the empty-state row has its own --empty class)
entries = self.browser.find_elements(By.CSS_SELECTOR, ".bud-entry") entries = self.browser.find_elements(By.CSS_SELECTOR, ".bud-entry")
self.assertEqual(len(entries), 0) 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 ""
))

View File

@@ -136,6 +136,123 @@ describe('Brief.showBanner', () => {
}); });
// ─────────────────────────────────────────────────────────────────────────────
//
// Brief.showDuplicateBanner — the "already added" error variant. Distinct
// from showBanner because: the server doesn't persist a Brief row for these
// (transient, client-only), title is rendered as `@<username> is already
// present`, there's no date/square/post_url, and the FYI button toggles
// `.bud-duplicate-flash` onto a caller-supplied target element instead of
// navigating.
//
// API under test:
// Brief.showDuplicateBanner({ display_name, target_selector? })
// null / missing display_name → no banner
// target_selector matches an element → FYI click adds .bud-duplicate-flash
// target_selector missing or matches nothing → FYI just dismisses
//
// ─────────────────────────────────────────────────────────────────────────────
describe('Brief.showDuplicateBanner', () => {
let fixture;
beforeEach(() => {
fixture = document.createElement('div');
fixture.id = 'dup-fixture';
fixture.innerHTML =
'<h2>Dash</h2>' +
'<ul id="bud-fixture-list">' +
'<li class="bud-entry" data-bud-id="42">' +
'<span class="bud-name">alice</span>' +
'</li>' +
'</ul>';
document.body.appendChild(fixture);
});
afterEach(() => {
document.querySelectorAll('.note-banner').forEach(b => b.remove());
fixture.remove();
});
it('D1: missing display_name → no banner', () => {
Brief.showDuplicateBanner({});
expect(document.querySelector('.note-banner')).toBeNull();
});
it('D2: title reads "@<display_name> is already present"', () => {
Brief.showDuplicateBanner({ display_name: 'alice' });
const title = document.querySelector('.note-banner__title');
expect(title).not.toBeNull();
expect(title.textContent).toBe('@alice is already present');
});
it('D3: banner carries the .note-banner--duplicate modifier class', () => {
Brief.showDuplicateBanner({ display_name: 'alice' });
const b = document.querySelector('.note-banner');
expect(b).not.toBeNull();
expect(b.classList.contains('note-banner--duplicate')).toBeTrue();
});
it('D4: duplicate banner has no .note-banner__description or .note-banner__timestamp', () => {
Brief.showDuplicateBanner({ display_name: 'alice' });
expect(document.querySelector('.note-banner__description')).toBeNull();
expect(document.querySelector('.note-banner__timestamp')).toBeNull();
});
it('D5: FYI button is a <button>, not an <a> (no navigation)', () => {
Brief.showDuplicateBanner({ display_name: 'alice' });
const fyi = document.querySelector('.note-banner .note-banner__fyi');
expect(fyi).not.toBeNull();
expect(fyi.tagName.toLowerCase()).toBe('button');
});
it('D6: NVM dismisses without adding .bud-duplicate-flash anywhere', () => {
Brief.showDuplicateBanner({
display_name: 'alice',
target_selector: '.bud-entry[data-bud-id="42"] .bud-name',
});
document.querySelector('.note-banner__nvm').click();
expect(document.querySelector('.note-banner')).toBeNull();
const name = fixture.querySelector('.bud-name');
expect(name.classList.contains('bud-duplicate-flash')).toBeFalse();
});
it('D7: FYI dismisses AND adds .bud-duplicate-flash to the target element', () => {
Brief.showDuplicateBanner({
display_name: 'alice',
target_selector: '.bud-entry[data-bud-id="42"] .bud-name',
});
document.querySelector('.note-banner__fyi').click();
expect(document.querySelector('.note-banner')).toBeNull();
const name = fixture.querySelector('.bud-name');
expect(name.classList.contains('bud-duplicate-flash')).toBeTrue();
});
it('D8: FYI dismisses cleanly when target_selector is missing', () => {
Brief.showDuplicateBanner({ display_name: 'alice' });
document.querySelector('.note-banner__fyi').click();
expect(document.querySelector('.note-banner')).toBeNull();
});
it('D9: FYI dismisses cleanly when target_selector matches nothing', () => {
Brief.showDuplicateBanner({
display_name: 'alice',
target_selector: '.gate-slot[data-user-id="does-not-exist"]',
});
document.querySelector('.note-banner__fyi').click();
expect(document.querySelector('.note-banner')).toBeNull();
});
it('D10: title escapes HTML in display_name', () => {
Brief.showDuplicateBanner({ display_name: '<script>x</script>' });
const title = document.querySelector('.note-banner__title');
expect(title.textContent).toBe('@<script>x</script> is already present');
expect(title.querySelector('script')).toBeNull();
});
});
describe('Brief.handleSaveResponse', () => { describe('Brief.handleSaveResponse', () => {
afterEach(() => { afterEach(() => {

View File

@@ -171,3 +171,17 @@ html:has(#id_kit_bag_dialog[open]) #id_bud_btn {
outline: none; outline: none;
} }
} }
// ── Duplicate-add highlight ─────────────────────────────────────────────
//
// Eased-in flash applied by Brief.showDuplicateBanner's FYI button to a
// caller-supplied target element — one of .bud-entry .bud-name (My Buds),
// .post-recipient (post share), or .gate-slot.filled (gatekeeper invite).
// Persists until page refresh; --terUser color + --ninUser text-shadow
// per the duplicate-guard spec.
.bud-duplicate-flash {
color: rgba(var(--terUser), 1);
text-shadow: 0 0 0.5em rgba(var(--ninUser), 1);
transition: color 600ms ease, text-shadow 600ms ease;
}

View File

@@ -136,6 +136,123 @@ describe('Brief.showBanner', () => {
}); });
// ─────────────────────────────────────────────────────────────────────────────
//
// Brief.showDuplicateBanner — the "already added" error variant. Distinct
// from showBanner because: the server doesn't persist a Brief row for these
// (transient, client-only), title is rendered as `@<username> is already
// present`, there's no date/square/post_url, and the FYI button toggles
// `.bud-duplicate-flash` onto a caller-supplied target element instead of
// navigating.
//
// API under test:
// Brief.showDuplicateBanner({ display_name, target_selector? })
// null / missing display_name → no banner
// target_selector matches an element → FYI click adds .bud-duplicate-flash
// target_selector missing or matches nothing → FYI just dismisses
//
// ─────────────────────────────────────────────────────────────────────────────
describe('Brief.showDuplicateBanner', () => {
let fixture;
beforeEach(() => {
fixture = document.createElement('div');
fixture.id = 'dup-fixture';
fixture.innerHTML =
'<h2>Dash</h2>' +
'<ul id="bud-fixture-list">' +
'<li class="bud-entry" data-bud-id="42">' +
'<span class="bud-name">alice</span>' +
'</li>' +
'</ul>';
document.body.appendChild(fixture);
});
afterEach(() => {
document.querySelectorAll('.note-banner').forEach(b => b.remove());
fixture.remove();
});
it('D1: missing display_name → no banner', () => {
Brief.showDuplicateBanner({});
expect(document.querySelector('.note-banner')).toBeNull();
});
it('D2: title reads "@<display_name> is already present"', () => {
Brief.showDuplicateBanner({ display_name: 'alice' });
const title = document.querySelector('.note-banner__title');
expect(title).not.toBeNull();
expect(title.textContent).toBe('@alice is already present');
});
it('D3: banner carries the .note-banner--duplicate modifier class', () => {
Brief.showDuplicateBanner({ display_name: 'alice' });
const b = document.querySelector('.note-banner');
expect(b).not.toBeNull();
expect(b.classList.contains('note-banner--duplicate')).toBeTrue();
});
it('D4: duplicate banner has no .note-banner__description or .note-banner__timestamp', () => {
Brief.showDuplicateBanner({ display_name: 'alice' });
expect(document.querySelector('.note-banner__description')).toBeNull();
expect(document.querySelector('.note-banner__timestamp')).toBeNull();
});
it('D5: FYI button is a <button>, not an <a> (no navigation)', () => {
Brief.showDuplicateBanner({ display_name: 'alice' });
const fyi = document.querySelector('.note-banner .note-banner__fyi');
expect(fyi).not.toBeNull();
expect(fyi.tagName.toLowerCase()).toBe('button');
});
it('D6: NVM dismisses without adding .bud-duplicate-flash anywhere', () => {
Brief.showDuplicateBanner({
display_name: 'alice',
target_selector: '.bud-entry[data-bud-id="42"] .bud-name',
});
document.querySelector('.note-banner__nvm').click();
expect(document.querySelector('.note-banner')).toBeNull();
const name = fixture.querySelector('.bud-name');
expect(name.classList.contains('bud-duplicate-flash')).toBeFalse();
});
it('D7: FYI dismisses AND adds .bud-duplicate-flash to the target element', () => {
Brief.showDuplicateBanner({
display_name: 'alice',
target_selector: '.bud-entry[data-bud-id="42"] .bud-name',
});
document.querySelector('.note-banner__fyi').click();
expect(document.querySelector('.note-banner')).toBeNull();
const name = fixture.querySelector('.bud-name');
expect(name.classList.contains('bud-duplicate-flash')).toBeTrue();
});
it('D8: FYI dismisses cleanly when target_selector is missing', () => {
Brief.showDuplicateBanner({ display_name: 'alice' });
document.querySelector('.note-banner__fyi').click();
expect(document.querySelector('.note-banner')).toBeNull();
});
it('D9: FYI dismisses cleanly when target_selector matches nothing', () => {
Brief.showDuplicateBanner({
display_name: 'alice',
target_selector: '.gate-slot[data-user-id="does-not-exist"]',
});
document.querySelector('.note-banner__fyi').click();
expect(document.querySelector('.note-banner')).toBeNull();
});
it('D10: title escapes HTML in display_name', () => {
Brief.showDuplicateBanner({ display_name: '<script>x</script>' });
const title = document.querySelector('.note-banner__title');
expect(title.textContent).toBe('@<script>x</script> is already present');
expect(title.querySelector('script')).toBeNull();
});
});
describe('Brief.handleSaveResponse', () => { describe('Brief.handleSaveResponse', () => {
afterEach(() => { afterEach(() => {

View File

@@ -56,6 +56,9 @@
onSuccess: function (data) { onSuccess: function (data) {
if (data.bud) _appendBudEntry(data.bud); if (data.bud) _appendBudEntry(data.bud);
}, },
duplicateTargetSelector: function (data) {
return '.bud-entry[data-bud-id="' + data.recipient_user_id + '"] .bud-name';
},
}); });
}()); }());
</script> </script>

View File

@@ -35,5 +35,12 @@ bindBudBtn({
onSuccess: function (data) { onSuccess: function (data) {
if (window.Brief && data.brief) Brief.showBanner(data.brief); if (window.Brief && data.brief) Brief.showBanner(data.brief);
}, },
duplicateTargetSelector: function (data) {
// Pending RoomInvite duplicates have no recipient_user_id (no
// visible slot to highlight); selector returning null is fine —
// showDuplicateBanner's FYI just dismisses without easing.
if (!data.recipient_user_id) return null;
return '.gate-slot.filled[data-user-id="' + data.recipient_user_id + '"]';
},
}); });
</script> </script>

View File

@@ -68,7 +68,9 @@
// under .post-header. State transitions: // under .post-header. State transitions:
// 0 → 1+ recipients : "just me, X" → "shared between {chip}" + "& me, X" // 0 → 1+ recipients : "just me, X" → "shared between {chip}" + "& me, X"
// ≥1 → +1 recipients: append chip + ", " separator before existing. // ≥1 → +1 recipients: append chip + ", " separator before existing.
function _appendRecipientChip(displayName) { // `userId` is stamped onto the chip as data-user-id so a later duplicate-
// share attempt can highlight this same element via .bud-duplicate-flash.
function _appendRecipientChip(displayName, userId) {
if (!displayName) return; if (!displayName) return;
var header = document.querySelector('.post-page .post-header'); var header = document.querySelector('.post-page .post-header');
if (!header) return; if (!header) return;
@@ -78,6 +80,7 @@
var chip = document.createElement('span'); var chip = document.createElement('span');
chip.className = 'post-recipient'; chip.className = 'post-recipient';
chip.textContent = displayName; chip.textContent = displayName;
if (userId) chip.dataset.userId = userId;
if (existingRecipients) { if (existingRecipients) {
existingRecipients.appendChild(document.createTextNode(', ')); existingRecipients.appendChild(document.createTextNode(', '));
@@ -103,7 +106,12 @@
onSuccess: function (data) { onSuccess: function (data) {
if (data.line_text) _appendLine(data.line_text); if (data.line_text) _appendLine(data.line_text);
if (window.Brief && data.brief) Brief.showBanner(data.brief); if (window.Brief && data.brief) Brief.showBanner(data.brief);
if (data.recipient_display) _appendRecipientChip(data.recipient_display); if (data.recipient_display) {
_appendRecipientChip(data.recipient_display, data.recipient_user_id);
}
},
duplicateTargetSelector: function (data) {
return '.post-recipient[data-user-id="' + data.recipient_user_id + '"]';
}, },
}); });
}()); }());

View File

@@ -1,4 +1,5 @@
{% extends "core/base.html" %} {% extends "core/base.html" %}
{% load static %}
{% load lyric_extras %} {% load lyric_extras %}
{% block title_text %}Billbuds{% endblock title_text %} {% block title_text %}Billbuds{% endblock title_text %}
@@ -13,3 +14,9 @@
{# Bud btn (bottom-left) + slide-out add-bud panel — async POST to add_bud. #} {# Bud btn (bottom-left) + slide-out add-bud panel — async POST to add_bud. #}
{% include "apps/billboard/_partials/_bud_add_panel.html" %} {% include "apps/billboard/_partials/_bud_add_panel.html" %}
{% endblock content %} {% endblock content %}
{% block scripts %}
{# Brief module — needed by _bud_add_panel's OK handler so the #}
{# duplicate-add error banner can render via Brief.showDuplicateBanner.#}
<script src="{% static 'apps/dashboard/note.js' %}"></script>
{% endblock scripts %}

View File

@@ -18,7 +18,7 @@
{# Owner viewing — owner-centric prose. "shared between" lists #} {# Owner viewing — owner-centric prose. "shared between" lists #}
{# every recipient; the self line is the owner's own handle. #} {# every recipient; the self line is the owner's own handle. #}
{% if other_recipients %} {% if other_recipients %}
<p class="post-shared-recipients">shared between {% for r in other_recipients %}<span class="post-recipient post-attribution">{{ r|at_handle }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p> <p class="post-shared-recipients">shared between {% for r in other_recipients %}<span class="post-recipient post-attribution" data-user-id="{{ r.id }}">{{ r|at_handle }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p>
<p class="post-shared-self">&amp; me, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p> <p class="post-shared-self">&amp; me, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
{% else %} {% else %}
<p class="post-shared-self">just me, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p> <p class="post-shared-self">just me, <span class="post-attribution">{{ post.owner|at_handle }} the {{ post.owner.active_title_display }}</span></p>
@@ -28,7 +28,7 @@
{# (request.user). Sole invitee collapses to a single line; the #} {# (request.user). Sole invitee collapses to a single line; the #}
{# "created by …" line attributes the post to its founder. #} {# "created by …" line attributes the post to its founder. #}
{% if other_recipients %} {% if other_recipients %}
<p class="post-shared-recipients">shared with {% for r in other_recipients %}<span class="post-recipient post-attribution">{{ r|at_handle }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p> <p class="post-shared-recipients">shared with {% for r in other_recipients %}<span class="post-recipient post-attribution" data-user-id="{{ r.id }}">{{ r|at_handle }}</span>{% if not forloop.last %}, {% endif %}{% endfor %}</p>
<p class="post-shared-self">&amp; me, <span class="post-attribution">{{ request.user|at_handle }} the {{ request.user.active_title_display }}</span></p> <p class="post-shared-self">&amp; me, <span class="post-attribution">{{ request.user|at_handle }} the {{ request.user.active_title_display }}</span></p>
{% else %} {% else %}
<p class="post-shared-self">shared with me, <span class="post-attribution">{{ request.user|at_handle }} the {{ request.user.active_title_display }}</span></p> <p class="post-shared-self">shared with me, <span class="post-attribution">{{ request.user|at_handle }} the {{ request.user.active_title_display }}</span></p>

View File

@@ -1,7 +1,7 @@
<div class="position-strip"> <div class="position-strip">
{% for pos in gate_positions %} {% for pos in gate_positions %}
<div class="gate-slot{% if pos.slot.status == 'EMPTY' %} empty{% elif pos.slot.status == 'FILLED' %} filled{% elif pos.slot.status == 'RESERVED' %} reserved{% endif %}{% if pos.role_assigned %} role-assigned{% endif %}" <div class="gate-slot{% if pos.slot.status == 'EMPTY' %} empty{% elif pos.slot.status == 'FILLED' %} filled{% elif pos.slot.status == 'RESERVED' %} reserved{% endif %}{% if pos.role_assigned %} role-assigned{% endif %}"
data-slot="{{ pos.slot.slot_number }}"> data-slot="{{ pos.slot.slot_number }}"{% if pos.slot.gamer %} data-user-id="{{ pos.slot.gamer.id }}"{% endif %}>
<span class="slot-number">{{ pos.slot.slot_number }}</span> <span class="slot-number">{{ pos.slot.slot_number }}</span>
<span class="slot-gamer">{% if pos.slot.gamer %}{{ pos.slot.gamer.email }}{% else %}empty{% endif %}</span> <span class="slot-gamer">{% if pos.slot.gamer %}{{ pos.slot.gamer.email }}{% else %}empty{% endif %}</span>
{% if pos.slot.status == 'RESERVED' and pos.slot.gamer == request.user %} {% if pos.slot.status == 'RESERVED' and pos.slot.gamer == request.user %}