diff --git a/src/apps/applets/migrations/0007_rename_my_buddies_to_my_buds.py b/src/apps/applets/migrations/0007_rename_my_buddies_to_my_buds.py new file mode 100644 index 0000000..c18d388 --- /dev/null +++ b/src/apps/applets/migrations/0007_rename_my_buddies_to_my_buds.py @@ -0,0 +1,40 @@ +"""Rename the My Buddies applet → My Buds (slug + name). + +UI-vocabulary tightening — see lyric/0005_rename_buddies_to_buds for the +parallel User.buddies → User.buds field rename. BILLBUDDIES overflowed +the page-header band; BILLBUDS fits cleanly. +""" +from django.db import migrations + + +def forward(apps, schema_editor): + Applet = apps.get_model("applets", "Applet") + try: + applet = Applet.objects.get(slug="my-buddies") + except Applet.DoesNotExist: + return + applet.slug = "my-buds" + applet.name = "My Buds" + applet.save(update_fields=["slug", "name"]) + + +def backward(apps, schema_editor): + Applet = apps.get_model("applets", "Applet") + try: + applet = Applet.objects.get(slug="my-buds") + except Applet.DoesNotExist: + return + applet.slug = "my-buddies" + applet.name = "My Buddies" + applet.save(update_fields=["slug", "name"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("applets", "0006_rename_contacts_to_buddies"), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/src/apps/billboard/migrations/0006_alter_line_options.py b/src/apps/billboard/migrations/0006_alter_line_options.py new file mode 100644 index 0000000..cf52335 --- /dev/null +++ b/src/apps/billboard/migrations/0006_alter_line_options.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0 on 2026-05-09 03:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('billboard', '0005_line_admin_solicited'), + ] + + operations = [ + migrations.AlterModelOptions( + name='line', + options={'ordering': ('created_at', 'id')}, + ), + ] diff --git a/src/apps/billboard/tests/integrated/test_buddies.py b/src/apps/billboard/tests/integrated/test_buddies.py deleted file mode 100644 index 3ece2f5..0000000 --- a/src/apps/billboard/tests/integrated/test_buddies.py +++ /dev/null @@ -1,143 +0,0 @@ -"""ITs for the My Buddies feature (User.buddies M2M + my_buddies view + -add_buddy JSON endpoint). - -User.buddies is a self M2M (symmetrical=False) — adding Alice to Disco's -list does NOT auto-reciprocate. Implicit auto-add on shared events -(post-share, gate-invite) is layered separately in those views. - -Privacy: add_buddy returns 200 with {buddy: null} when the email is -unregistered, so the response shape never leaks membership. -""" -from django.test import TestCase -from django.urls import reverse - -from apps.lyric.models import User - - -class UserBuddiesM2MTest(TestCase): - """The buddies field is asymmetric — A.buddies.add(B) doesn't - reciprocate to B.buddies, only to B.added_as_buddy.""" - - def setUp(self): - self.disco = User.objects.create(email="disco@test.io") - self.alice = User.objects.create(email="alice@test.io") - - def test_add_buddy_one_way(self): - self.disco.buddies.add(self.alice) - self.assertIn(self.alice, self.disco.buddies.all()) - self.assertNotIn(self.disco, self.alice.buddies.all()) - - def test_added_as_buddy_reverse_relation(self): - self.disco.buddies.add(self.alice) - self.assertIn(self.disco, self.alice.added_as_buddy.all()) - - def test_add_is_idempotent(self): - self.disco.buddies.add(self.alice) - self.disco.buddies.add(self.alice) - self.assertEqual(self.disco.buddies.count(), 1) - - -class MyBuddiesViewTest(TestCase): - def setUp(self): - self.user = User.objects.create(email="me@test.io") - self.client.force_login(self.user) - self.alice = User.objects.create(email="alice@test.io", username="alice") - self.bob = User.objects.create(email="bob@test.io", username="bob") - self.user.buddies.add(self.alice, self.bob) - - def test_my_buddies_renders_template(self): - response = self.client.get(reverse("billboard:my_buddies")) - self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, "apps/billboard/my_buddies.html") - - def test_my_buddies_lists_users_buddies(self): - response = self.client.get(reverse("billboard:my_buddies")) - buddies = list(response.context["buddies"]) - self.assertIn(self.alice, buddies) - self.assertIn(self.bob, buddies) - - def test_my_buddies_does_not_list_others_buddies(self): - other = User.objects.create(email="other@test.io") - carol = User.objects.create(email="carol@test.io", username="carol") - other.buddies.add(carol) - response = self.client.get(reverse("billboard:my_buddies")) - self.assertNotIn(carol, list(response.context["buddies"])) - - def test_my_buddies_redirects_anon_to_login(self): - self.client.logout() - response = self.client.get(reverse("billboard:my_buddies")) - self.assertEqual(response.status_code, 302) - - -class AddBuddyViewTest(TestCase): - def setUp(self): - self.user = User.objects.create(email="me@test.io", username="me") - self.client.force_login(self.user) - - def test_add_registered_email_adds_to_buddies(self): - alice = User.objects.create(email="alice@test.io", username="alice") - response = self.client.post( - reverse("billboard:add_buddy"), - data={"recipient": "alice@test.io"}, - ) - self.assertEqual(response.status_code, 200) - self.assertIn(alice, self.user.buddies.all()) - - def test_add_returns_buddy_payload_with_username(self): - User.objects.create(email="alice@test.io", username="alice") - response = self.client.post( - reverse("billboard:add_buddy"), - data={"recipient": "alice@test.io"}, - ) - body = response.json() - self.assertIsNotNone(body["buddy"]) - self.assertEqual(body["buddy"]["username"], "alice") - - def test_add_unregistered_email_returns_null_buddy(self): - """Privacy: 200 with buddy=null so the response shape doesn't leak - whether the address is on the system.""" - response = self.client.post( - reverse("billboard:add_buddy"), - data={"recipient": "ghost@test.io"}, - ) - self.assertEqual(response.status_code, 200) - self.assertIsNone(response.json()["buddy"]) - self.assertEqual(self.user.buddies.count(), 0) - - def test_add_own_email_is_silent_noop(self): - """Adding yourself: no buddy added, response carries buddy=null.""" - response = self.client.post( - reverse("billboard:add_buddy"), - data={"recipient": "me@test.io"}, - ) - self.assertEqual(response.status_code, 200) - self.assertIsNone(response.json()["buddy"]) - self.assertNotIn(self.user, self.user.buddies.all()) - - def test_add_existing_buddy_is_idempotent(self): - alice = User.objects.create(email="alice@test.io", username="alice") - self.user.buddies.add(alice) - response = self.client.post( - reverse("billboard:add_buddy"), - data={"recipient": "alice@test.io"}, - ) - self.assertEqual(response.status_code, 200) - # Still only one buddy entry — M2M dedup - self.assertEqual(self.user.buddies.count(), 1) - # Response still carries the buddy payload (so the JS can refresh - # an entry if a fast double-click bypassed the data-buddy-id guard). - self.assertIsNotNone(response.json()["buddy"]) - - def test_add_falls_back_to_email_when_no_username(self): - """Buddy payload returns email when buddy.username is None — display - layer matches the navbar fallback (display_name filter).""" - User.objects.create(email="anon@test.io") - response = self.client.post( - reverse("billboard:add_buddy"), - data={"recipient": "anon@test.io"}, - ) - self.assertEqual(response.json()["buddy"]["username"], "anon@test.io") - - def test_get_returns_405(self): - response = self.client.get(reverse("billboard:add_buddy")) - self.assertEqual(response.status_code, 405) diff --git a/src/apps/billboard/tests/integrated/test_buds.py b/src/apps/billboard/tests/integrated/test_buds.py new file mode 100644 index 0000000..5046345 --- /dev/null +++ b/src/apps/billboard/tests/integrated/test_buds.py @@ -0,0 +1,143 @@ +"""ITs for the My Buds feature (User.buds M2M + my_buds view + +add_bud JSON endpoint). + +User.buds is a self M2M (symmetrical=False) — adding Alice to Disco's +list does NOT auto-reciprocate. Implicit auto-add on shared events +(post-share, gate-invite) is layered separately in those views. + +Privacy: add_bud returns 200 with {bud: null} when the email is +unregistered, so the response shape never leaks membership. +""" +from django.test import TestCase +from django.urls import reverse + +from apps.lyric.models import User + + +class UserBudsM2MTest(TestCase): + """The buds field is asymmetric — A.buds.add(B) doesn't + reciprocate to B.buds, only to B.added_as_bud.""" + + def setUp(self): + self.disco = User.objects.create(email="disco@test.io") + self.alice = User.objects.create(email="alice@test.io") + + def test_add_bud_one_way(self): + self.disco.buds.add(self.alice) + self.assertIn(self.alice, self.disco.buds.all()) + self.assertNotIn(self.disco, self.alice.buds.all()) + + def test_added_as_bud_reverse_relation(self): + self.disco.buds.add(self.alice) + self.assertIn(self.disco, self.alice.added_as_bud.all()) + + def test_add_is_idempotent(self): + self.disco.buds.add(self.alice) + self.disco.buds.add(self.alice) + self.assertEqual(self.disco.buds.count(), 1) + + +class MyBudsViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="me@test.io") + self.client.force_login(self.user) + self.alice = User.objects.create(email="alice@test.io", username="alice") + self.bob = User.objects.create(email="bob@test.io", username="bob") + self.user.buds.add(self.alice, self.bob) + + def test_my_buds_renders_template(self): + response = self.client.get(reverse("billboard:my_buds")) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "apps/billboard/my_buds.html") + + def test_my_buds_lists_users_buds(self): + response = self.client.get(reverse("billboard:my_buds")) + buds = list(response.context["buds"]) + self.assertIn(self.alice, buds) + self.assertIn(self.bob, buds) + + def test_my_buds_does_not_list_others_buds(self): + other = User.objects.create(email="other@test.io") + carol = User.objects.create(email="carol@test.io", username="carol") + other.buds.add(carol) + response = self.client.get(reverse("billboard:my_buds")) + self.assertNotIn(carol, list(response.context["buds"])) + + def test_my_buds_redirects_anon_to_login(self): + self.client.logout() + response = self.client.get(reverse("billboard:my_buds")) + self.assertEqual(response.status_code, 302) + + +class AddBudViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="me@test.io", username="me") + self.client.force_login(self.user) + + def test_add_registered_email_adds_to_buds(self): + alice = User.objects.create(email="alice@test.io", username="alice") + response = self.client.post( + reverse("billboard:add_bud"), + data={"recipient": "alice@test.io"}, + ) + self.assertEqual(response.status_code, 200) + self.assertIn(alice, self.user.buds.all()) + + def test_add_returns_bud_payload_with_username(self): + User.objects.create(email="alice@test.io", username="alice") + response = self.client.post( + reverse("billboard:add_bud"), + data={"recipient": "alice@test.io"}, + ) + body = response.json() + self.assertIsNotNone(body["bud"]) + self.assertEqual(body["bud"]["username"], "alice") + + def test_add_unregistered_email_returns_null_bud(self): + """Privacy: 200 with bud=null so the response shape doesn't leak + whether the address is on the system.""" + response = self.client.post( + reverse("billboard:add_bud"), + data={"recipient": "ghost@test.io"}, + ) + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.json()["bud"]) + self.assertEqual(self.user.buds.count(), 0) + + def test_add_own_email_is_silent_noop(self): + """Adding yourself: no bud added, response carries bud=null.""" + response = self.client.post( + reverse("billboard:add_bud"), + data={"recipient": "me@test.io"}, + ) + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.json()["bud"]) + self.assertNotIn(self.user, self.user.buds.all()) + + def test_add_existing_bud_is_idempotent(self): + alice = User.objects.create(email="alice@test.io", username="alice") + self.user.buds.add(alice) + response = self.client.post( + reverse("billboard:add_bud"), + data={"recipient": "alice@test.io"}, + ) + self.assertEqual(response.status_code, 200) + # Still only one bud entry — M2M dedup + self.assertEqual(self.user.buds.count(), 1) + # Response still carries the bud payload (so the JS can refresh + # an entry if a fast double-click bypassed the data-bud-id guard). + self.assertIsNotNone(response.json()["bud"]) + + def test_add_falls_back_to_email_when_no_username(self): + """Bud payload returns email when bud.username is None — display + layer matches the navbar fallback (display_name filter).""" + User.objects.create(email="anon@test.io") + response = self.client.post( + reverse("billboard:add_bud"), + data={"recipient": "anon@test.io"}, + ) + self.assertEqual(response.json()["bud"]["username"], "anon@test.io") + + def test_get_returns_405(self): + response = self.client.get(reverse("billboard:add_bud")) + self.assertEqual(response.status_code, 405) diff --git a/src/apps/billboard/tests/integrated/test_views.py b/src/apps/billboard/tests/integrated/test_views.py index 0b56438..b5ed60e 100644 --- a/src/apps/billboard/tests/integrated/test_views.py +++ b/src/apps/billboard/tests/integrated/test_views.py @@ -13,7 +13,7 @@ from apps.lyric.models import User def _seed_billboard_applets(): for slug, name, cols, rows in [ ("my-scrolls", "My Scrolls", 4, 3), - ("my-buddies", "My Buddies", 4, 3), + ("my-buds", "My Buds", 4, 3), ("most-recent-scroll", "Most Recent Scroll", 8, 6), ]: Applet.objects.get_or_create( @@ -37,7 +37,7 @@ class BillboardViewTest(TestCase): self.assertIn("applets", response.context) slugs = [e["applet"].slug for e in response.context["applets"]] self.assertIn("my-scrolls", slugs) - self.assertIn("my-buddies", slugs) + self.assertIn("my-buds", slugs) self.assertIn("most-recent-scroll", slugs) def test_passes_my_rooms_context(self): @@ -111,7 +111,7 @@ class ToggleBillboardAppletsTest(TestCase): ) self.assertEqual(response.status_code, 302) from apps.applets.models import UserApplet - contacts = Applet.objects.get(slug="my-buddies") + contacts = Applet.objects.get(slug="my-buds") ua = UserApplet.objects.get(user=self.user, applet=contacts) self.assertFalse(ua.visible) @@ -136,7 +136,7 @@ class ToggleBillboardAppletsTest(TestCase): reverse("billboard:toggle_applets"), {"applets": [ "my-scrolls", - "my-buddies", + "my-buds", "most-recent-scroll", ]}, HTTP_HX_REQUEST="true", @@ -160,7 +160,7 @@ class ToggleBillboardAppletsTest(TestCase): self.assertEqual(body.count('id="id_billboard_applet_menu"'), 1) def test_second_toggle_preserves_prior_hidden_state(self): - # First toggle: hide My Buddies only. + # First toggle: hide My Buds only. self.client.post( reverse("billboard:toggle_applets"), {"applets": [ @@ -170,7 +170,7 @@ class ToggleBillboardAppletsTest(TestCase): ]}, HTTP_HX_REQUEST="true", ) - # Second toggle: hide Most Recent Scroll additionally — My Buddies must stay hidden. + # Second toggle: hide Most Recent Scroll additionally — My Buds must stay hidden. self.client.post( reverse("billboard:toggle_applets"), {"applets": [ @@ -180,7 +180,7 @@ class ToggleBillboardAppletsTest(TestCase): HTTP_HX_REQUEST="true", ) from apps.applets.models import UserApplet - contacts = Applet.objects.get(slug="my-buddies") + contacts = Applet.objects.get(slug="my-buds") most_recent_scroll = Applet.objects.get(slug="most-recent-scroll") self.assertFalse( UserApplet.objects.get(user=self.user, applet=contacts).visible diff --git a/src/apps/billboard/urls.py b/src/apps/billboard/urls.py index 8adea9b..e21a836 100644 --- a/src/apps/billboard/urls.py +++ b/src/apps/billboard/urls.py @@ -18,6 +18,6 @@ urlpatterns = [ path("post//", views.view_post, name="view_post"), path("post//share-post", views.share_post, name="share_post"), path("users//", views.my_posts, name="my_posts"), - path("my-buddies/", views.my_buddies, name="my_buddies"), - path("buddies/add", views.add_buddy, name="add_buddy"), + path("my-buds/", views.my_buds, name="my_buds"), + path("buds/add", views.add_bud, name="add_bud"), ] diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index d73fab4..cef4075 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -300,7 +300,7 @@ def my_posts(request, user_id): return HttpResponseForbidden() return render(request, "apps/billboard/my_posts.html", { "owner": owner, - "page_class": "page-billboard", + "page_class": "page-billposts", }) @@ -373,44 +373,44 @@ def share_post(request, post_id): return redirect(our_post) -# ── My Buddies ──────────────────────────────────────────────────────────── -# User.buddies is an asymmetric self M2M (lyric/0004). `my_buddies` is the -# manage-page; `add_buddy` is the JSON endpoint hit by the buddy panel slide- -# out. Privacy: when an entered email isn't a registered User, we 200 with -# {buddy: null} so the response shape doesn't leak membership. +# ── My Buds ─────────────────────────────────────────────────────────────── +# User.buds is an asymmetric self M2M (lyric/0004 + 0005 rename). `my_buds` +# is the manage-page; `add_bud` is the JSON endpoint hit by the bud-panel +# slide-out. Privacy: when an entered email isn't a registered User, we +# 200 with {bud: null} so the response shape doesn't leak membership. @login_required(login_url="/") -def my_buddies(request): - return render(request, "apps/billboard/my_buddies.html", { - "buddies": request.user.buddies.all(), - "page_class": "page-billbuddies", +def my_buds(request): + return render(request, "apps/billboard/my_buds.html", { + "buds": request.user.buds.all(), + "page_class": "page-billbuds", }) @login_required(login_url="/") -def add_buddy(request): +def add_bud(request): if request.method != "POST": from django.http import HttpResponseNotAllowed return HttpResponseNotAllowed(["POST"]) email = (request.POST.get("recipient") or "").strip() - buddy = None + bud = None try: candidate = User.objects.get(email=email) except User.DoesNotExist: candidate = None if candidate is not None and candidate != request.user: - if candidate not in request.user.buddies.all(): - request.user.buddies.add(candidate) - buddy = { + if candidate not in request.user.buds.all(): + request.user.buds.add(candidate) + bud = { "id": str(candidate.id), "username": candidate.username or candidate.email, "email": candidate.email, } - return JsonResponse({"buddy": buddy}) + return JsonResponse({"bud": bud}) @login_required(login_url="/") diff --git a/src/apps/dashboard/static/apps/dashboard/note.js b/src/apps/dashboard/static/apps/dashboard/note.js index 76c7693..11f0eda 100644 --- a/src/apps/dashboard/static/apps/dashboard/note.js +++ b/src/apps/dashboard/static/apps/dashboard/note.js @@ -83,6 +83,6 @@ const Note = Brief; // `const Brief = (...)` at script-tag scope is reachable as a bare name but // is NOT auto-attached to window — explicit assignment so callers that gate -// on `if (window.Brief)` (e.g. _buddy_panel.html's OK handler) succeed. +// on `if (window.Brief)` (e.g. _bud_panel.html's OK handler) succeed. window.Brief = Brief; window.Note = Note; diff --git a/src/apps/lyric/migrations/0005_rename_buddies_to_buds.py b/src/apps/lyric/migrations/0005_rename_buddies_to_buds.py new file mode 100644 index 0000000..930a4df --- /dev/null +++ b/src/apps/lyric/migrations/0005_rename_buddies_to_buds.py @@ -0,0 +1,32 @@ +"""Rename User.buddies → User.buds. + +Django's RenameField doesn't rename the implicit M2M through table +(`lyric_user_buddies` → `lyric_user_buds`), so we drop and re-add the +field. The buddies M2M was introduced one commit prior (0004) — no +production data to preserve. UI-vocabulary tightening (BILLBUDDIES +overflowed the page-header band; in-game term collapses to BILLBUDS). +""" +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("lyric", "0004_user_buddies"), + ] + + operations = [ + migrations.RemoveField( + model_name="user", + name="buddies", + ), + migrations.AddField( + model_name="user", + name="buds", + field=models.ManyToManyField( + blank=True, + related_name="added_as_bud", + to="lyric.user", + ), + ), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index dd9baaa..8ab8103 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -113,13 +113,13 @@ class User(AbstractBaseUser): unlocked_decks = models.ManyToManyField( "epic.DeckVariant", blank=True, related_name="unlocked_by", ) - # Asymmetric self M2M — `user.buddies.all()` = people I've explicitly + # Asymmetric self M2M — `user.buds.all()` = people I've explicitly # added (or implicitly via post-share / game-invite, which auto-adds - # the recipient to the inviter's buddies list). `user.added_as_buddy` - # = the inverse (people who have me in their buddies list); useful - # for the future "buddy changed username" snapshot-accept flow. - buddies = models.ManyToManyField( - "self", symmetrical=False, blank=True, related_name="added_as_buddy", + # the recipient to the inviter's buds list). `user.added_as_bud` is + # the inverse (people who have me in their buds list); useful for + # the future "bud changed username" snapshot-accept flow. + buds = models.ManyToManyField( + "self", symmetrical=False, blank=True, related_name="added_as_bud", ) active_title = models.ForeignKey( "drama.Note", null=True, blank=True, diff --git a/src/functional_tests/post_page.py b/src/functional_tests/post_page.py index 0d73a99..2f76ccf 100644 --- a/src/functional_tests/post_page.py +++ b/src/functional_tests/post_page.py @@ -55,17 +55,17 @@ class PostPage: ) def share_post_with(self, email): - # Buddy-btn flow (post-Brief sprint): click bottom-left handshake, + # Bud-btn flow (post-Brief sprint): click bottom-left handshake, # type the email in the slide-out, click the .btn-confirm OK, wait # for the recipient chip. - buddy_btn = self.test.browser.find_element(By.ID, "id_buddy_btn") - buddy_btn.click() + bud_btn = self.test.browser.find_element(By.ID, "id_bud_btn") + bud_btn.click() recipient = self.test.wait_for( lambda: self.test.browser.find_element(By.ID, "id_recipient") ) recipient.send_keys(email) ok = self.test.browser.find_element( - By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm" + By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm" ) ok.click() self.test.wait_for( diff --git a/src/functional_tests/test_admin_post_readonly.py b/src/functional_tests/test_admin_post_readonly.py index b00511d..8c27cc6 100644 --- a/src/functional_tests/test_admin_post_readonly.py +++ b/src/functional_tests/test_admin_post_readonly.py @@ -59,21 +59,21 @@ class AdminPostInputReadonlyTest(FunctionalTest): ) -class AdminPostHasNoBuddyBtnTest(FunctionalTest): - """Admin-Post (note-unlock thread) suppresses #id_buddy_btn — friend +class AdminPostHasNoBudBtnTest(FunctionalTest): + """Admin-Post (note-unlock thread) suppresses #id_bud_btn — friend invites don't apply to system-authored threads. User-Post still - renders the btn (regression coverage in test_buddy_btn.py).""" + renders the btn (regression coverage in test_bud_btn.py).""" def setUp(self): super().setUp() - self.gamer = User.objects.create(email="nobuddy@test.io") + self.gamer = User.objects.create(email="nobud@test.io") Note.grant_if_new(self.gamer, "stargazer") self.admin_post = Post.objects.get( owner=self.gamer, kind=Post.KIND_NOTE_UNLOCK, ) - self.create_pre_authenticated_session("nobuddy@test.io") + self.create_pre_authenticated_session("nobud@test.io") - def test_buddy_btn_absent_on_admin_post(self): + def test_bud_btn_absent_on_admin_post(self): self.browser.get( self.live_server_url + f"/billboard/post/{self.admin_post.id}/" ) @@ -82,8 +82,8 @@ class AdminPostHasNoBuddyBtnTest(FunctionalTest): lambda: self.browser.find_element(By.ID, "id_post_line_text") ) self.assertFalse( - self.browser.find_elements(By.ID, "id_buddy_btn"), - "Admin-Post must NOT render #id_buddy_btn", + self.browser.find_elements(By.ID, "id_bud_btn"), + "Admin-Post must NOT render #id_bud_btn", ) diff --git a/src/functional_tests/test_billboard.py b/src/functional_tests/test_billboard.py index b0af2e9..a93b98e 100644 --- a/src/functional_tests/test_billboard.py +++ b/src/functional_tests/test_billboard.py @@ -26,7 +26,7 @@ class BillboardScrollTest(FunctionalTest): super().setUp() for slug, name, cols, rows in [ ("my-scrolls", "My Scrolls", 4, 3), - ("my-buddies", "My Buddies", 4, 3), + ("my-buds", "My Buds", 4, 3), ("most-recent-scroll", "Most Recent Scroll", 8, 6), ]: Applet.objects.get_or_create( @@ -193,7 +193,7 @@ class BillscrollPositionTest(FunctionalTest): class BillboardAppletsTest(FunctionalTest): """ FT: billboard page renders three applets in the grid — My Scrolls, - My Buddies, and Most Recent Scroll — with a functioning gear menu. + My Buds, and Most Recent Scroll — with a functioning gear menu. """ def setUp(self): @@ -203,7 +203,7 @@ class BillboardAppletsTest(FunctionalTest): self.room = Room.objects.create(name="Arcane Assembly", owner=self.founder) for slug, name, cols, rows in [ ("my-scrolls", "My Scrolls", 4, 3), - ("my-buddies", "My Buddies", 4, 3), + ("my-buds", "My Buds", 4, 3), ("most-recent-scroll", "Most Recent Scroll", 8, 6), ]: Applet.objects.get_or_create( @@ -219,7 +219,7 @@ class BillboardAppletsTest(FunctionalTest): self.wait_for( lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls") ) - self.browser.find_element(By.ID, "id_applet_my_buddies") + self.browser.find_element(By.ID, "id_applet_my_buds") self.browser.find_element(By.ID, "id_applet_most_recent_scroll") def test_billboard_my_scrolls_lists_rooms(self): @@ -278,7 +278,7 @@ class BillboardAppletsTest(FunctionalTest): lambda: self.browser.find_element(By.ID, "id_billboard_applet_menu") ) contacts_cb = menu.find_element( - By.CSS_SELECTOR, "input[value='my-buddies']" + By.CSS_SELECTOR, "input[value='my-buds']" ) self.browser.execute_script("arguments[0].click()", contacts_cb) menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click() @@ -286,7 +286,7 @@ class BillboardAppletsTest(FunctionalTest): # Contacts is hidden; Most Recent Scroll + My Scrolls keep their content (bug #2) self.wait_for( lambda: self.assertEqual( - self.browser.find_elements(By.ID, "id_applet_my_buddies"), + self.browser.find_elements(By.ID, "id_applet_my_buds"), [], ) ) @@ -305,7 +305,7 @@ class BillboardAppletsTest(FunctionalTest): ) # The freshly-rendered menu must reflect DB state (Contacts unchecked) contacts_cb = menu.find_element( - By.CSS_SELECTOR, "input[value='my-buddies']" + By.CSS_SELECTOR, "input[value='my-buds']" ) self.assertFalse(contacts_cb.is_selected()) most_recent_scroll_cb = menu.find_element( @@ -322,7 +322,7 @@ class BillboardAppletsTest(FunctionalTest): ) ) self.assertEqual( - self.browser.find_elements(By.ID, "id_applet_my_buddies"), + self.browser.find_elements(By.ID, "id_applet_my_buds"), [], ) @@ -332,7 +332,7 @@ class BillboardAppletsTest(FunctionalTest): lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls") ) self.assertEqual( - self.browser.find_elements(By.ID, "id_applet_my_buddies"), + self.browser.find_elements(By.ID, "id_applet_my_buds"), [], ) self.assertEqual( diff --git a/src/functional_tests/test_buddy_btn.py b/src/functional_tests/test_bud_btn.py similarity index 79% rename from src/functional_tests/test_buddy_btn.py rename to src/functional_tests/test_bud_btn.py index 6792572..c411ba1 100644 --- a/src/functional_tests/test_buddy_btn.py +++ b/src/functional_tests/test_bud_btn.py @@ -1,9 +1,9 @@ -"""FT spec for the Buddy btn sprint — post.html bottom-left handshake button. +"""FT spec for the Bud btn sprint — post.html bottom-left handshake button. Written red BEFORE implementation as a TDD handoff so the post-compaction agent (or future Disco) can land the feature without losing intent. Run: - python src/manage.py test functional_tests.test_buddy_btn + python src/manage.py test functional_tests.test_bud_btn All tests should be RED initially. Implementation lands when they go green. @@ -11,22 +11,22 @@ All tests should be RED initially. Implementation lands when they go green. SPEC SUMMARY ──────────────────────────────────────────────────────────────────────────── - • A new #id_buddy_btn () sits bottom-left + • A new #id_bud_btn () sits bottom-left of the viewport — mirror of #id_kit_btn (bottom-right). Shares the same fixed/circular/secUser-bordered look + .active-state styling. - • Lives in a partial template, e.g. apps/billboard/_partials/_buddy_panel.html, - included only by post.html (NOT the global base.html — buddy is post-only). + • Lives in a partial template, e.g. apps/billboard/_partials/_bud_panel.html, + included only by post.html (NOT the global base.html — bud is post-only). • Replaces the inline share form on post.html: typing the recipient now - happens in a slide-out under the buddy btn. - • Click #id_buddy_btn → recipient field grows L→R under it, spanning + happens in a slide-out under the bud btn. + • Click #id_bud_btn → recipient field grows L→R under it, spanning `100vw - 3rem` (1.5rem padding each side). The field is vertically - centred on the buddy btn's centre, w. healthy left padding so the typed + centred on the bud btn's centre, w. healthy left padding so the typed text + placeholder don't overlap the btn glyph. • An OK btn (.btn.btn-confirm) is tacked on the trailing edge of the field (replaces the legacy big SHARE .btn-primary). - • While the recipient field is open: html.buddy-open is set; #id_kit_btn + • While the recipient field is open: html.bud-open is set; #id_kit_btn quickly eases to opacity 0. Symmetric: when html.kit-open is set, - #id_buddy_btn eases to opacity 0. + #id_bud_btn eases to opacity 0. • Click OK → POST share-post async (existing C3.b endpoint), clears the field, closes the slide-out, slide-down Brief banner appears. • Click outside the field/btn → closes the slide-out, clears the field @@ -40,29 +40,29 @@ SPEC SUMMARY IMPLEMENTATION CHECKLIST (post-compaction) ──────────────────────────────────────────────────────────────────────────── - 1. Create templates/apps/billboard/_partials/_buddy_panel.html w. the btn + 1. Create templates/apps/billboard/_partials/_bud_panel.html w. the btn + slide-out form + inline JS. 2. Edit templates/apps/billboard/post.html: - Drop the inline `
` + #id_recipient + SHARE btn - block (the share JS moves into _buddy_panel.html). - - Add `{% include "apps/billboard/_partials/_buddy_panel.html" %}`. + block (the share JS moves into _bud_panel.html). + - Add `{% include "apps/billboard/_partials/_bud_panel.html" %}`. 3. Edit billboard.views.view_post (or my_posts) context: - "page_class": "page-billboard" (or new page-post) so the body class picks up the aperture SCSS. - 4. SCSS — add to _game-kit.scss neighbour or new _buddy.scss: - - #id_buddy_btn: position fixed bottom-left, mirror of #id_kit_btn + 4. SCSS — add to _game-kit.scss neighbour or new _bud.scss: + - #id_bud_btn: position fixed bottom-left, mirror of #id_kit_btn (3rem circle, secUser border, .active state, etc.) - - #id_buddy_panel (the slide-out wrapper): position fixed, + - #id_bud_panel (the slide-out wrapper): position fixed, left: 1.5rem, right: 1.5rem (or 1.5rem + #id_kit_btn width when kit btn visible — but mutual-exclusion makes that moot), bottom- aligned w. the btn centre, transition transform/width L→R. - - html.buddy-open #id_kit_btn { opacity: 0; transition: opacity 0.15s; } - - html.kit-open #id_buddy_btn { opacity: 0; transition: opacity 0.15s; } + - html.bud-open #id_kit_btn { opacity: 0; transition: opacity 0.15s; } + - html.kit-open #id_bud_btn { opacity: 0; transition: opacity 0.15s; } - The OK .btn-confirm gets normal btn-pad styling; flex-shrink:0 on the trailing edge of the slide-out. - 5. JS in _buddy_panel.html: + 5. JS in _bud_panel.html: - Mirror game-kit.js click/escape/click-outside pattern. - - Toggle html.buddy-open + #id_buddy_btn.active. + - Toggle html.bud-open + #id_bud_btn.active. - On submit/OK: fetch POST share-post w. Accept:application/json, reuse the C3.b response handling (line append, banner via Brief.showBanner, recipient_display chip append). @@ -70,7 +70,7 @@ IMPLEMENTATION CHECKLIST (post-compaction) 6. post.html + my_posts.html: add the body class hook so the aperture SCSS engages (probably page-billboard already, just need to confirm). 7. Update functional_tests.post_page.PostPage.share_post_with() to - drive the buddy-btn flow (click btn → type → click OK → wait for chip). + drive the bud-btn flow (click btn → type → click OK → wait for chip). 8. Re-run test_sharing.SharingTest — should still pass once the page- object mirrors the new flow. @@ -82,7 +82,7 @@ KNOWN AT TIME OF WRITING - The C3.b share-post async endpoint accepts Accept: application/json, returns {brief, line_text, recipient_display}; intercepted by the inline JS in post.html's existing #id_share_form. That JS moves into - _buddy_panel.html. + _bud_panel.html. - body.page-billboard class is set by billboard:billboard view; post.html needs it (or its own class) added in billboard.views.view_post. """ @@ -104,8 +104,8 @@ def _seed_a_post(user): return p -class BuddyBtnPresenceTest(FunctionalTest): - """The buddy btn is post-only — present on post.html, absent elsewhere.""" +class BudBtnPresenceTest(FunctionalTest): + """The bud btn is post-only — present on post.html, absent elsewhere.""" def setUp(self): super().setUp() @@ -113,46 +113,46 @@ class BuddyBtnPresenceTest(FunctionalTest): slug="my-posts", defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"}, ) - self.gamer = User.objects.create(email="buddy@test.io") + self.gamer = User.objects.create(email="bud@test.io") self.post = _seed_a_post(self.gamer) - self.create_pre_authenticated_session("buddy@test.io") + self.create_pre_authenticated_session("bud@test.io") # ── B1 ────────────────────────────────────────────────────────────────── - def test_buddy_btn_renders_on_post_html(self): + def test_bud_btn_renders_on_post_html(self): self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") - btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) + btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) icon = btn.find_element(By.CSS_SELECTOR, "i.fa-solid.fa-handshake") self.assertIsNotNone(icon) # ── B2 ────────────────────────────────────────────────────────────────── - def test_buddy_btn_absent_on_dashboard(self): + def test_bud_btn_absent_on_dashboard(self): self.browser.get(self.live_server_url + "/") # Allow page to settle self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn")) - self.assertFalse(self.browser.find_elements(By.ID, "id_buddy_btn")) + self.assertFalse(self.browser.find_elements(By.ID, "id_bud_btn")) # ── B3 ────────────────────────────────────────────────────────────────── - def test_buddy_btn_absent_on_billboard_index(self): + def test_bud_btn_absent_on_billboard_index(self): self.browser.get(self.live_server_url + "/billboard/") self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn")) - self.assertFalse(self.browser.find_elements(By.ID, "id_buddy_btn")) + self.assertFalse(self.browser.find_elements(By.ID, "id_bud_btn")) -class BuddyBtnPositionTest(FunctionalTest): +class BudBtnPositionTest(FunctionalTest): """The btn sits bottom-left, mirror of #id_kit_btn's bottom-right.""" def setUp(self): super().setUp() - self.gamer = User.objects.create(email="buddy@test.io") + self.gamer = User.objects.create(email="bud@test.io") self.post = _seed_a_post(self.gamer) - self.create_pre_authenticated_session("buddy@test.io") + self.create_pre_authenticated_session("bud@test.io") self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") - def test_buddy_btn_is_fixed_bottom_left(self): - btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) + def test_bud_btn_is_fixed_bottom_left(self): + btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) cs = self.browser.execute_script( "var s = getComputedStyle(arguments[0]); " "return {position: s.position, bottom: s.bottom, left: s.left, right: s.right};", @@ -164,9 +164,9 @@ class BuddyBtnPositionTest(FunctionalTest): self.assertNotEqual(cs["bottom"], "auto") self.assertNotEqual(cs["left"], "auto") - def test_buddy_btn_size_matches_kit_btn(self): + def test_bud_btn_size_matches_kit_btn(self): """Same circular-3rem look — visually a mirror pair.""" - btn = self.browser.find_element(By.ID, "id_buddy_btn") + btn = self.browser.find_element(By.ID, "id_bud_btn") kit = self.browser.find_element(By.ID, "id_kit_btn") b_box = btn.size k_box = kit.size @@ -174,47 +174,47 @@ class BuddyBtnPositionTest(FunctionalTest): self.assertEqual(b_box["height"], k_box["height"]) -class BuddyBtnSlideOutTest(FunctionalTest): - """Click the buddy btn → recipient field + OK btn slide out under it.""" +class BudBtnSlideOutTest(FunctionalTest): + """Click the bud btn → recipient field + OK btn slide out under it.""" def setUp(self): super().setUp() - self.gamer = User.objects.create(email="buddy@test.io") + self.gamer = User.objects.create(email="bud@test.io") self.post = _seed_a_post(self.gamer) - self.create_pre_authenticated_session("buddy@test.io") + self.create_pre_authenticated_session("bud@test.io") self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") def test_recipient_field_hidden_until_click(self): """Pre-click: the field is in DOM but visually closed (e.g. width 0 or transform scaleX(0)) — assertion checks it doesn't take its full viewport-spanning width yet.""" - btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) + btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) # The field+OK panel is rendered (so JS can transition it) but should # be in a closed state — assert the panel container exists and the # input is not displayed at full width (a CSS-driven slide-out). - panel = self.browser.find_element(By.ID, "id_buddy_panel") + panel = self.browser.find_element(By.ID, "id_bud_panel") # Before click, panel visible-width should be < viewport / 2 (closed) viewport_w = self.browser.execute_script("return window.innerWidth;") self.assertLess(panel.size["width"], viewport_w / 2) - def test_click_buddy_btn_reveals_recipient_field_and_ok_btn(self): - btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) + def test_click_bud_btn_reveals_recipient_field_and_ok_btn(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")) self.assertTrue(recipient.is_displayed()) # OK btn is .btn.btn-confirm tacked onto the panel — not a big SHARE - ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm") + ok = self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm") self.assertEqual(ok.text.strip().upper(), "OK") - # Buddy btn picks up .active when open (mirror kit-btn pattern) + # Bud btn picks up .active when open (mirror kit-btn pattern) self.assertIn("active", btn.get_attribute("class")) def test_panel_spans_almost_full_viewport_when_open(self): """When open, the panel spans 100vw - 3rem (1.5rem each side).""" - btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) + btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) btn.click() - panel = self.browser.find_element(By.ID, "id_buddy_panel") + panel = self.browser.find_element(By.ID, "id_bud_panel") # Wait for the slide-out transition to settle self.wait_for(lambda: self.assertGreater( panel.size["width"], @@ -222,32 +222,32 @@ class BuddyBtnSlideOutTest(FunctionalTest): )) def test_recipient_input_has_left_padding_so_glyph_doesnt_overlap(self): - btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) + 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")) pad = self.browser.execute_script( "return parseFloat(getComputedStyle(arguments[0]).paddingLeft);", recipient, ) - # At least 2.5rem (40px-ish) so the buddy glyph (3rem circle) doesn't + # At least 2.5rem (40px-ish) so the bud glyph (3rem circle) doesn't # overlap the placeholder/typed text. self.assertGreaterEqual(pad, 32) -class BuddyKitMutualExclusionTest(FunctionalTest): - """When kit btn is active, buddy btn fades to 0 — and vice-versa.""" +class BudKitMutualExclusionTest(FunctionalTest): + """When kit btn is active, bud btn fades to 0 — and vice-versa.""" def setUp(self): super().setUp() - self.gamer = User.objects.create(email="buddy@test.io") + self.gamer = User.objects.create(email="bud@test.io") self.post = _seed_a_post(self.gamer) - self.create_pre_authenticated_session("buddy@test.io") + self.create_pre_authenticated_session("bud@test.io") self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") - def test_buddy_active_fades_kit_btn(self): - buddy = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) + def test_bud_active_fades_kit_btn(self): + bud = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) kit = self.browser.find_element(By.ID, "id_kit_btn") - buddy.click() + bud.click() self.wait_for(lambda: self.assertEqual( self.browser.execute_script( "return parseFloat(getComputedStyle(arguments[0]).opacity);", @@ -256,32 +256,32 @@ class BuddyKitMutualExclusionTest(FunctionalTest): 0.0, )) - def test_kit_active_fades_buddy_btn(self): + def test_kit_active_fades_bud_btn(self): kit = self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn")) - buddy = self.browser.find_element(By.ID, "id_buddy_btn") + bud = self.browser.find_element(By.ID, "id_bud_btn") kit.click() self.wait_for(lambda: self.assertEqual( self.browser.execute_script( "return parseFloat(getComputedStyle(arguments[0]).opacity);", - buddy, + bud, ), 0.0, )) -class BuddyBtnDismissTest(FunctionalTest): +class BudBtnDismissTest(FunctionalTest): """Click outside / Escape closes the panel; field is cleared; reopening shows the placeholder, not the previously-typed value.""" def setUp(self): super().setUp() - self.gamer = User.objects.create(email="buddy@test.io") + self.gamer = User.objects.create(email="bud@test.io") self.post = _seed_a_post(self.gamer) - self.create_pre_authenticated_session("buddy@test.io") + self.create_pre_authenticated_session("bud@test.io") self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") def _open_and_type(self, text): - btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) + 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(text) @@ -310,24 +310,24 @@ class BuddyBtnDismissTest(FunctionalTest): self.assertEqual(recipient.get_attribute("value"), "") -class BuddyBtnOkSubmitsAsyncShareTest(FunctionalTest): +class BudBtnOkSubmitsAsyncShareTest(FunctionalTest): """OK → POST share-post (Accept:application/json) → Brief banner + recipient chip appended; field clears; panel closes.""" def setUp(self): super().setUp() - self.sharer = User.objects.create(email="buddy@test.io") + self.sharer = User.objects.create(email="bud@test.io") self.recipient = User.objects.create(email="alice@test.io") self.post = _seed_a_post(self.sharer) - self.create_pre_authenticated_session("buddy@test.io") + self.create_pre_authenticated_session("bud@test.io") self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") def test_ok_creates_brief_appends_line_and_chip(self): - btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) + btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) btn.click() recipient_input = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient")) recipient_input.send_keys("alice@test.io") - ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm") + ok = self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm") ok.click() # 1. Brief is created server-side @@ -358,9 +358,9 @@ class PostHtmlAperturePageClassTest(FunctionalTest): slug="my-posts", defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"}, ) - self.gamer = User.objects.create(email="buddy@test.io") + self.gamer = User.objects.create(email="bud@test.io") self.post = _seed_a_post(self.gamer) - self.create_pre_authenticated_session("buddy@test.io") + self.create_pre_authenticated_session("bud@test.io") def test_post_html_body_carries_billboard_or_post_page_class(self): self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/") diff --git a/src/functional_tests/test_game_kit.py b/src/functional_tests/test_game_kit.py index 6cdaccf..2a24479 100644 --- a/src/functional_tests/test_game_kit.py +++ b/src/functional_tests/test_game_kit.py @@ -134,7 +134,7 @@ class PronounsAppletFlowTest(FunctionalTest): # Billboard applets — page renders blank without these for slug, name, cols, rows in [ ("my-scrolls", "My Scrolls", 4, 3), - ("my-buddies", "My Buddies", 4, 3), + ("my-buds", "My Buds", 4, 3), ("most-recent-scroll", "Most Recent Scroll", 8, 6), ]: Applet.objects.get_or_create( diff --git a/src/functional_tests/test_my_buddies.py b/src/functional_tests/test_my_buds.py similarity index 71% rename from src/functional_tests/test_my_buddies.py rename to src/functional_tests/test_my_buds.py index 16dd7a9..8158bd9 100644 --- a/src/functional_tests/test_my_buddies.py +++ b/src/functional_tests/test_my_buds.py @@ -1,6 +1,6 @@ -"""FT for the My Buddies page — buddy btn + slide-out add flow. +"""FT for the My Buds page — bud btn + slide-out add flow. -Phase 1 of the buddies sprint: explicit add via my_buddies.html. Phase 2 +Phase 1 of the buds sprint: explicit add via my_buds.html. Phase 2 will layer autocomplete (sky-place-style top-3 username suggestions) and implicit auto-add on post-share / gate-invite. """ @@ -12,64 +12,64 @@ from apps.lyric.models import User from .base import FunctionalTest -class MyBuddiesPageTest(FunctionalTest): +class MyBudsPageTest(FunctionalTest): def setUp(self): super().setUp() self.gamer = User.objects.create(email="me@test.io", username="me") self.alice = User.objects.create(email="alice@test.io", username="alice") self.create_pre_authenticated_session("me@test.io") - def test_renders_existing_buddies(self): - """Pre-existing buddies show up as entries on first render.""" - self.gamer.buddies.add(self.alice) - self.browser.get(self.live_server_url + "/billboard/my-buddies/") + def test_renders_existing_buds(self): + """Pre-existing buds show up as entries on first render.""" + self.gamer.buds.add(self.alice) + self.browser.get(self.live_server_url + "/billboard/my-buds/") entry = self.wait_for( - lambda: self.browser.find_element(By.CSS_SELECTOR, ".buddy-entry .buddy-name") + lambda: self.browser.find_element(By.CSS_SELECTOR, ".bud-entry .bud-name") ) self.assertEqual(entry.text, "alice") - def test_empty_state_when_no_buddies(self): - self.browser.get(self.live_server_url + "/billboard/my-buddies/") + def test_empty_state_when_no_buds(self): + self.browser.get(self.live_server_url + "/billboard/my-buds/") empty = self.wait_for( - lambda: self.browser.find_element(By.CSS_SELECTOR, ".buddy-entry--empty") + lambda: self.browser.find_element(By.CSS_SELECTOR, ".applet-list-entry--empty") ) - self.assertIn("No buddies yet", empty.text) + self.assertIn("No buds yet", empty.text) - def test_add_buddy_via_buddy_btn_appends_entry(self): - self.browser.get(self.live_server_url + "/billboard/my-buddies/") - btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) + def test_add_bud_via_bud_btn_appends_entry(self): + 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") - ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm") + ok = self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm") ok.click() # New entry appears w. alice's username (not the bare email) self.wait_for(lambda: self.assertEqual( self.browser.find_element( - By.CSS_SELECTOR, f".buddy-entry[data-buddy-id='{self.alice.id}'] .buddy-name" + By.CSS_SELECTOR, f".bud-entry[data-bud-id='{self.alice.id}'] .bud-name" ).text, "alice", )) # Server-side persisted self.wait_for(lambda: self.assertIn( - self.alice, list(self.gamer.buddies.all()) + self.alice, list(self.gamer.buds.all()) )) def test_add_unregistered_email_is_silent_noop(self): - self.browser.get(self.live_server_url + "/billboard/my-buddies/") - btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_buddy_btn")) + 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("ghost@test.io") - ok = self.browser.find_element(By.CSS_SELECTOR, "#id_buddy_panel .btn.btn-confirm") + ok = self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm") ok.click() # Wait for the panel close (a positive signal the request landed) self.wait_for(lambda: self.assertNotIn( "active", btn.get_attribute("class") )) - # No entries beyond the empty-state row - entries = self.browser.find_elements(By.CSS_SELECTOR, ".buddy-entry:not(.buddy-entry--empty)") + # 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) diff --git a/src/static_src/scss/_billboard.scss b/src/static_src/scss/_billboard.scss index e5ffeed..317fbec 100644 --- a/src/static_src/scss/_billboard.scss +++ b/src/static_src/scss/_billboard.scss @@ -36,14 +36,16 @@ html:has(body.page-billboard), html:has(body.page-billscroll), html:has(body.page-billpost), -html:has(body.page-billbuddies) { +html:has(body.page-billbuds), +html:has(body.page-billposts) { overflow: hidden; } body.page-billboard, body.page-billscroll, body.page-billpost, -body.page-billbuddies { +body.page-billbuds, +body.page-billposts { overflow: hidden; .container { @@ -217,54 +219,62 @@ body.page-billbuddies { } } -// ── My Buddies page (aperture list + add-buddy panel) ──────────────────── -// Mirrors .post-page's flex-column / overflow / bottom-anchor pattern; -// the add-buddy panel is included from the same _buddy*.scss styling. +// ── Applet-list page (Billbuds, Billposts) ─────────────────────────────── +// Shared shell for pages built around _applet-list-shell.html — vertical +// title rotated on the left of an .applet-scroll card + scrollable
    +// aperture. `--single` hosts one section (My Buds); `--two-up` stacks +// two sections in portrait, places them side-by-side in landscape (My +// Posts: own + shared). -.buddies-page { +.applet-list-page { @extend %billboard-page-base; display: flex; flex-direction: column; padding: 0.75rem; - gap: 0.5rem; + gap: 0.75rem; - .buddies-header { - flex-shrink: 0; + .applet-scroll { + @extend %applet-box; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; - .buddies-title { - margin: 0 0 0.25rem; - font-weight: bold; + .applet-list { + list-style: none; + margin: 0; + padding: 0 0.75rem 0 0; + flex: 1; + min-height: 0; + overflow-y: auto; + } + + .applet-list-entry { + padding: 0.4rem 0; + + .bud-name { font-weight: bold; opacity: 0.85; } + + &--empty { opacity: 0.6; font-style: italic; } + + a { + color: inherit; + text-decoration: none; + font-weight: bold; + &:hover { opacity: 0.85; } + } + } + + .applet-list-buffer { + flex-shrink: 0; + height: 0.5rem; } } - #id_buddies_list { - list-style: none; - margin: 0; - padding: 0 0.75rem 0 0; - flex: 1; - min-height: 0; - overflow-y: auto; - display: flex; - flex-direction: column; - justify-content: flex-end; - - .buddy-entry { - padding: 0.4rem 0; - - .buddy-name { - font-weight: bold; - opacity: 0.85; - } - - &--empty { - opacity: 0.6; - font-style: italic; - } - } - - .buddy-entry-buffer { - flex-shrink: 0; - height: 0.25rem; + // Side-by-side in landscape; stacked in portrait (default). + &--two-up { + @media (orientation: landscape) { + flex-direction: row; + .applet-scroll { flex: 1; } } } } @@ -276,13 +286,13 @@ body.page-billbuddies { #id_billboard_applets_container { #id_applet_my_scrolls { grid-column: 1 / span 4; grid-row: 1 / span 3; } - #id_applet_my_buddies { grid-column: 1 / span 4; grid-row: 4 / span 3; } + #id_applet_my_buds { grid-column: 1 / span 4; grid-row: 4 / span 3; } #id_applet_notes { grid-column: 1 / span 4; grid-row: 7 / span 4; } #id_applet_most_recent_scroll { grid-column: 5 / span 8; grid-row: 1 / span 10; } @container (max-width: 550px) { #id_applet_my_scrolls, - #id_applet_my_buddies, + #id_applet_my_buds, #id_applet_notes, #id_applet_most_recent_scroll { grid-column: 1 / span 12; diff --git a/src/static_src/scss/_buddy.scss b/src/static_src/scss/_bud.scss similarity index 77% rename from src/static_src/scss/_buddy.scss rename to src/static_src/scss/_bud.scss index bdaba24..436a7ef 100644 --- a/src/static_src/scss/_buddy.scss +++ b/src/static_src/scss/_bud.scss @@ -1,13 +1,13 @@ -// ── Buddy btn (bottom-left mirror of #id_kit_btn) ───────────────────────── +// ── Bud btn (bottom-left mirror of #id_kit_btn) ───────────────────────── // // Lives on post.html only — slide-out recipient field for the share-post // async flow. Mutually exclusive w. #id_kit_btn (bottom-right): when one is -// active (.active class on btn + html.{kit|buddy}-open class on root), the +// active (.active class on btn + html.{kit|bud}-open class on root), the // other quickly fades to opacity 0. // -// Spec: functional_tests/test_buddy_btn.py. +// Spec: functional_tests/test_bud_btn.py. -#id_buddy_btn { +#id_bud_btn { position: fixed; bottom: 0.5rem; left: 0.5rem; @@ -43,12 +43,12 @@ } // Slide-out panel: collapsed by default; opens to span ~viewport - 3rem. -#id_buddy_panel { +#id_bud_panel { position: fixed; - bottom: 0.5rem; // align bottom edge w. buddy btn + bottom: 0.5rem; // align bottom edge w. bud btn left: 1.5rem; right: 1.5rem; - height: 3rem; // match buddy btn height for vertical-centre alignment + height: 3rem; // match bud btn height for vertical-centre alignment z-index: 317; display: flex; align-items: center; @@ -56,7 +56,7 @@ pointer-events: none; overflow: hidden; - // Closed state — collapse leftward into the buddy btn + // Closed state — collapse leftward into the bud btn transform-origin: left center; transform: scaleX(0); transition: transform 0.2s ease-out, opacity 0.15s ease; @@ -76,7 +76,7 @@ flex: 1; min-width: 0; height: 100%; - // Generous left padding so the buddy btn glyph (3rem circle pinned + // Generous left padding so the bud btn glyph (3rem circle pinned // at left:1.5rem) doesn't visually overlap the placeholder/typed text. padding: 0 1rem 0 3.5rem; background-color: rgba(var(--priUser), 1); @@ -97,9 +97,9 @@ } } -// html.buddy-open: slide the panel out, fade the kit btn away. -html.buddy-open { - #id_buddy_panel { +// html.bud-open: slide the panel out, fade the kit btn away. +html.bud-open { + #id_bud_panel { transform: scaleX(1); opacity: 1; pointer-events: auto; @@ -111,10 +111,10 @@ html.buddy-open { } } -// Kit dialog open: hide the buddy btn. We don't add an `html.kit-open` +// Kit dialog open: hide the bud btn. We don't add an `html.kit-open` // class (game-kit.js uses [open] on the dialog + .active on the btn), so // the mutual-exclusion is driven by `:has()` against the open dialog. -html:has(#id_kit_bag_dialog[open]) #id_buddy_btn { +html:has(#id_kit_bag_dialog[open]) #id_bud_btn { opacity: 0; pointer-events: none; } diff --git a/src/static_src/scss/core.scss b/src/static_src/scss/core.scss index 9c0ac8c..9ba27c6 100644 --- a/src/static_src/scss/core.scss +++ b/src/static_src/scss/core.scss @@ -13,7 +13,7 @@ @import 'note'; @import 'tooltips'; @import 'game-kit'; -@import 'buddy'; +@import 'bud'; @import 'wallet-tokens'; diff --git a/src/templates/apps/applets/_partials/_applet-list-shell.html b/src/templates/apps/applets/_partials/_applet-list-shell.html new file mode 100644 index 0000000..354c532 --- /dev/null +++ b/src/templates/apps/applets/_partials/_applet-list-shell.html @@ -0,0 +1,23 @@ +{# Shared applet-scroll-style list section — vertical-title

    on the #} +{# left + scrollable
      aperture. Inclusion shell (NOT a base template) #} +{# so a single page can invoke it more than once (e.g. my_posts.html #} +{# stacks "My Posts" + "Posts shared with me"). #} +{# #} +{# Parameters: #} +{# shell_title — vertical-rotated heading text (string) #} +{# shell_items — iterable rendered into the list #} +{# shell_item_template — partial rendering each
    • ; receives `item` #} +{# shell_list_id — optional `id=` for the
        (e.g. "id_buds_list" #} +{# so buddy-panel JS can target it) #} +{# shell_empty — text for the {% empty %} fallback row #} +
        +

        {{ shell_title }}

        +
          + {% for item in shell_items %} + {% include shell_item_template %} + {% empty %} +
        • {{ shell_empty|default:"Nothing here yet." }}
        • + {% endfor %} + +
        +
        diff --git a/src/templates/apps/billboard/_partials/_applet-my-buddies.html b/src/templates/apps/billboard/_partials/_applet-my-buds.html similarity index 58% rename from src/templates/apps/billboard/_partials/_applet-my-buddies.html rename to src/templates/apps/billboard/_partials/_applet-my-buds.html index a1a3c46..02955b7 100644 --- a/src/templates/apps/billboard/_partials/_applet-my-buddies.html +++ b/src/templates/apps/billboard/_partials/_applet-my-buds.html @@ -1,7 +1,7 @@
        -

        My Buddies

        +

        My Buds

        {% include "core/_partials/_forthcoming.html" %}
        diff --git a/src/templates/apps/billboard/_partials/_buddy_add_panel.html b/src/templates/apps/billboard/_partials/_bud_add_panel.html similarity index 65% rename from src/templates/apps/billboard/_partials/_buddy_add_panel.html rename to src/templates/apps/billboard/_partials/_bud_add_panel.html index 218f6e9..9ad69e8 100644 --- a/src/templates/apps/billboard/_partials/_buddy_add_panel.html +++ b/src/templates/apps/billboard/_partials/_bud_add_panel.html @@ -1,32 +1,32 @@ {% load static %} {# ─────────────────────────────────────────────────────────────────────── #} -{# _buddy_add_panel.html — bottom-left handshake btn + slide-out add- #} -{# buddy field. Mirrors _buddy_panel.html (post-share) but POSTs to #} -{# add_buddy and appends to #id_buddies_list instead of #id_post_table. #} -{# Included by my_buddies.html only. #} +{# _bud_add_panel.html — bottom-left handshake btn + slide-out add- #} +{# bud field. Mirrors _bud_panel.html (post-share) but POSTs to #} +{# add_bud and appends to #id_buds_list instead of #id_post_table. #} +{# Included by my_buds.html only. #} {# ─────────────────────────────────────────────────────────────────────── #} - -
        +
        - +