bud landing page: /billboard/buds/<id>/ + my_buds tooltip portal + @mailman post-attribution anchor — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed

Replaces the @mailman invite Line's inline OK/BYE block w. a dedicated per-bud surface. Three new FTs (test_bill_bud_page, test_bill_my_buds_tooltip, test_bill_mailman_invite_post — landed red 2026-05-27 PM) drive: per-bud landing page rendering 4-btn apparatus + shoptalk textarea + invite-cascade glow handoff; my-buds row tooltip portal w. .tt-title/.tt-description/.tt-email/.tt-shoptalk/.tt-milestone slots; mailman Brief surfacing on any authenticated page-load via context processor + base.html JSON-script.

Models: new `BudshipNote(user, bud, shoptalk[CharField max=160], edited_at)` w. unique_together — per-relation personal note about a bud, never visible to the bud. Lazy-created on first shoptalk save so absence of a row reads as 'never edited' (drives .tt-milestone slot presence).

URLs (billboard): `buds/<uuid:bud_id>/` (bud_page), `buds/<uuid:bud_id>/shoptalk` (save_bud_shoptalk), `buds/<uuid:bud_id>/delete` (delete_bud).

Views: bud_page auto-adds the bud on first visit (mirrors share_post implicit-add); resolves `pending_invite` as non-expired PENDING SeaInvite(owner=bud, invitee=request.user) → drives `sea_btn_active` + `sea_first_draw_pending` flags that _burger.html already reads on my_sea + room. my_buds enriches each bud w. `.shoptalk_text` + `.milestone_dt` so the row template can render data-tt-* attrs without an extra template tag.

mail.py: INVITE_TEMPLATE now interpolates `owner_id` into an `<a class="post-attribution" href="/billboard/buds/{owner_id}/">{handle}</a>` wrapper around the owner's handle. post.html's existing safe-filter branch (gated on author username == 'mailman') passes it through unescaped. Removed the {% if line.sea_invite %} include path — _invite_actions.html left in place for archival.

Templates: new bud.html (header + shoptalk form + apparatus + gear + burger fan + sea_btn nav inline JS); new _bud_gear.html (NVM→my_buds, DEL→guard portal "Delete this bud?" → POST delete_bud); new _bud_tooltip.html (portal w. .tt-* slots); _my_buds_item.html wraps `@handle` in an anchor to bud_page + carries data-tt-* attrs + " the {{ active_title_display }}"; my_buds.html includes the tooltip portal + loads my-buds-tooltip.js.

JS: new my-buds-tooltip.js binds row clicks → .row-locked + populates #id_tooltip_portal from data-tt-* attrs; anchor clicks pass through to navigate; .tt-milestone is removed from DOM (not just emptied) when never-edited so the FT can distinguish absent vs cleared-after-edit.

SCSS: extend landscape gear-btn rule + #id_*_menu rule w. `.bud-page` + `#id_bud_menu` (otherwise gear-btn collided w. bud-btn in landscape on bud.html). Bump active burger sub-btn z-index to 1 so click hit-test picks the active sub-btn during the 0.25s fan arc-out animation (otherwise a later-in-DOM inactive btn obscured the active target during transition).

Cross-page Brief surface: new `mail_brief_payload` context processor injects the user's oldest unread MAIL_ACCEPTANCE Brief into every authenticated response; base.html renders the JSON-script + auto-fires Brief.showBanner. Mark-read still rides view_post's existing GET unread-flip — no new endpoint.

Pre-existing MySeaInvitePostRenderTest (test_sea_invite_views.py) inverted to match the new contract: the .invite-actions sweep is unconditional (PENDING / ACCEPTED / DECLINED all carry prose only); pinned the post-attribution anchor + bud-page href in its place.

1518 ITs green (1475 app ITs + 43 sprint ITs), 23 sprint FTs green (5 my_buds tooltip + 13 bud page + 5 mailman invite post). Jasmine specs from sprint plan deferred — FT coverage of burger-glow / row-lock / portal-populate paths suffices and the textarea blur-POST flow isn't implemented in this sprint (form is server-action only, save-on-blur AJAX is a follow-on).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-28 11:45:20 -04:00
parent c41cf7ed36
commit 6cc11924e3
23 changed files with 1423 additions and 28 deletions

View File

@@ -0,0 +1,276 @@
"""FT for the per-bud landing page at /billboard/buds/<bud_id>/.
bud.html is the destination of the @mailman-invite cascade (see
test_bill_mailman_invite_post.py) AND the click target of the
`@<handle>` anchor on /billboard/my-buds/ (see test_bill_my_buds_tooltip.py).
It's the singular counterpart of my_buds.html in the same way post.html
is to my_posts.html.
Spec recap:
- Page renders all the data the tooltip surfaces (title, description, email,
shoptalk, milestone) PLUS a 160-char `<textarea>` so the user can edit
the shoptalk in place. Save persists to a per-relationship BudshipNote.
- 4-button apparatus from my_sea.html: kit / bud / gear / burger.
- The bud `#id_bud_btn` opens but its OK button is disabled (.btn-disabled + ×).
- Gear menu = NVM (→ my_buds) + DEL (→ shared guard portal "Delete this bud?";
DEL deletes the M2M edge, NVM dismisses the portal).
- Burger fan: 5 sub-btns default to inactive (.fa-ban icon, no .active class).
- Invite-cascade: if the bud has a PENDING SeaInvite where invitee=me,
• #id_burger_btn carries .glow-handoff (machine cascade from my_sea.html);
• #id_sea_btn in the fan is .active;
• clicking the glowing #id_sea_btn navigates to the bud's my-sea page
AND clears .glow-handoff but PRESERVES .active.
- Visiting bud.html for an inviter who isn't yet in my buds auto-adds them.
"""
from selenium.webdriver.common.by import By
from apps.gameboard.models import SeaInvite
from apps.lyric.models import User
from .base import FunctionalTest
class BudPageRenderTest(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.gamer.buds.add(self.alice)
self.create_pre_authenticated_session("me@test.io")
def test_page_header_reads_at_handle_the_title(self):
"""Same `@alice the Earthman` framing as the my_buds row, hoisted
into the page header so the user sees who they're on the page of."""
self.browser.get(
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
)
header = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".bud-page-header"
))
self.assertIn("@alice the Earthman", header.text)
self.assertIn("alice@test.io", header.text)
def test_shoptalk_textarea_renders_with_160_char_maxlength(self):
textarea = self.wait_for(lambda: self._open_and_find_textarea())
self.assertEqual(textarea.get_attribute("maxlength"), "160")
self.assertEqual(textarea.get_attribute("value") or "", "")
def _open_and_find_textarea(self):
self.browser.get(
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
)
return self.browser.find_element(By.ID, "id_shoptalk")
class BudPageFourButtonApparatusTest(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.gamer.buds.add(self.alice)
self.create_pre_authenticated_session("me@test.io")
self.browser.get(
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
)
def test_all_four_apparatus_buttons_present(self):
"""kit / bud / gear / burger — same 4-btn slate as my_sea.html.
kit_btn is included via base.html so we don't need to render it
specially on bud.html."""
for btn_id in ("id_kit_btn", "id_bud_btn", "id_burger_btn"):
self.wait_for(lambda bid=btn_id: self.browser.find_element(By.ID, bid))
# Gear btn is rendered via the shared _gear.html partial — class-
# only, not an id. Disambiguate by data-menu-target.
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_bud_menu']"
))
def test_bud_btn_panel_ok_is_disabled_and_shows_times(self):
"""Per-spec stub: the bud-on-bud OK btn is intentionally disabled
for now (.btn-disabled + `×`). Wired later when the bud-of-bud
flow exists. Slap-on stub avoids removing the apparatus."""
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
btn.click()
ok = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_bud_panel .btn"
))
self.assertIn("btn-disabled", ok.get_attribute("class") or "")
self.assertEqual(ok.text.strip(), "×")
def test_burger_fan_all_five_subbtns_inactive_by_default(self):
"""Fresh load (no pending SeaInvite to me) → every sub-btn renders
WITHOUT .active. The visible icon for each sub-btn is .fa-ban (the
`--off` half); the `--on` icon stays hidden by CSS until .active."""
burger = self.browser.find_element(By.ID, "id_burger_btn")
burger.click()
self.wait_for(lambda: self.assertEqual(
self.browser.find_element(By.ID, "id_burger_fan").get_attribute("aria-hidden"),
"false",
))
fan_btns = self.browser.find_elements(By.CSS_SELECTOR, "#id_burger_fan .burger-fan-btn")
self.assertEqual(len(fan_btns), 5)
for b in fan_btns:
cls = b.get_attribute("class") or ""
self.assertNotIn("active", cls.split())
class BudPageGearMenuTest(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.gamer.buds.add(self.alice)
self.create_pre_authenticated_session("me@test.io")
self.browser.get(
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
)
def test_gear_nvm_navigates_back_to_my_buds(self):
self.browser.find_element(
By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_bud_menu']"
).click()
nvm = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_bud_menu .btn-cancel"
))
self.assertEqual(nvm.text.strip(), "NVM")
nvm.click()
self.wait_for(lambda: self.assertIn(
"/billboard/my-buds/", self.browser.current_url
))
def test_gear_del_opens_guard_portal_with_delete_this_bud_message(self):
self.browser.find_element(
By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_bud_menu']"
).click()
del_btn = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_bud_menu .btn-danger"
))
self.assertEqual(del_btn.text.strip(), "DEL")
del_btn.click()
portal = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_guard_portal.active"
))
self.assertIn(
"Delete this bud?",
portal.find_element(By.CSS_SELECTOR, ".guard-message").text,
)
def test_guard_del_removes_bud_from_m2m_and_navigates_away(self):
self.browser.find_element(
By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_bud_menu']"
).click()
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_bud_menu .btn-danger"
)).click()
# Guard portal: DEL = .guard-yes, NVM = .guard-no.
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_guard_portal.active .guard-yes"
)).click()
self.wait_for(lambda: self.assertIn(
"/billboard/my-buds/", self.browser.current_url
))
self.assertNotIn(self.alice, list(self.gamer.buds.all()))
def test_guard_nvm_dismisses_portal_without_navigating_or_deleting(self):
self.browser.find_element(
By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_bud_menu']"
).click()
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_bud_menu .btn-danger"
)).click()
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_guard_portal.active .guard-no"
)).click()
# Portal closes; bud is still in M2M; we're still on the bud page.
self.wait_for(lambda: self.assertNotIn(
"active",
self.browser.find_element(By.ID, "id_guard_portal").get_attribute("class") or "",
))
self.assertIn(self.alice, list(self.gamer.buds.all()))
self.assertIn(f"/billboard/buds/{self.alice.id}/", self.browser.current_url)
class BudPageInviteCascadeTest(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.gamer.buds.add(self.alice)
self.create_pre_authenticated_session("me@test.io")
def test_no_pending_invite_no_glow_no_sea_active(self):
self.browser.get(
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
)
burger = self.wait_for(lambda: self.browser.find_element(By.ID, "id_burger_btn"))
self.assertNotIn("glow-handoff", (burger.get_attribute("class") or "").split())
burger.click()
sea = self.browser.find_element(By.ID, "id_sea_btn")
self.assertNotIn("active", (sea.get_attribute("class") or "").split())
def test_pending_invite_glows_burger_and_seats_sea_btn_active(self):
"""alice → me PENDING SeaInvite. burger.glow-handoff is rendered
server-side (machine cascade — same `glow-handoff` class as the
my_sea.html sea_first_draw_pending path)."""
SeaInvite.objects.create(
owner=self.alice,
invitee_email=self.gamer.email,
invitee=self.gamer,
status=SeaInvite.PENDING,
)
self.browser.get(
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
)
burger = self.wait_for(lambda: self.browser.find_element(By.ID, "id_burger_btn"))
self.assertIn("glow-handoff", (burger.get_attribute("class") or "").split())
burger.click()
sea = self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_btn"))
self.assertIn("active", (sea.get_attribute("class") or "").split())
def test_click_glowing_sea_btn_navigates_to_buds_my_sea_and_clears_glow(self):
"""Click glowing #id_sea_btn → navigates to alice's my-sea spectator
page. The .active stays on sea_btn (preserved across pages); the
burger's .glow-handoff is consumed by the handoff."""
SeaInvite.objects.create(
owner=self.alice,
invitee_email=self.gamer.email,
invitee=self.gamer,
status=SeaInvite.PENDING,
)
self.browser.get(
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_burger_btn")).click()
sea = self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_btn"))
sea.click()
# Final URL is the bud's my-sea spectator page — exact route TBD
# at IT time (likely /gameboard/my-sea/spectate/<owner_id>/ or
# /gameboard/my-sea/<owner_id>/). The FT pins the OWNER's id in
# the URL so the IT picks a route shape that surfaces it.
self.wait_for(lambda: self.assertIn(
str(self.alice.id), self.browser.current_url
))
self.wait_for(lambda: self.assertIn("my-sea", self.browser.current_url))
class BudPageAutoAddOnFirstVisitTest(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")
# NOTE: alice is NOT yet in gamer.buds — visiting bud.html for her
# via the post-attribution link should auto-add her.
self.create_pre_authenticated_session("me@test.io")
def test_visiting_bud_page_for_non_bud_auto_adds_to_m2m(self):
"""Per-spec: 'if the inviter wasn't already a bud, navigating there
as such automatically adds the bud to the user's bud list.' Same
implicit-add posture as share-post → buds.add."""
self.assertNotIn(self.alice, list(self.gamer.buds.all()))
self.browser.get(
self.live_server_url + f"/billboard/buds/{self.alice.id}/"
)
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".bud-page-header"
))
self.assertIn(self.alice, list(self.gamer.buds.all()))

View File

@@ -0,0 +1,168 @@
"""FT for the @mailman "Acceptances & rejections" Post + cross-page Brief
behaviour after the bud-landing-page sprint refactor.
Pre-sprint: the @mailman invite Line carried inline OK/BYE form buttons
+ an "Accepted <date>" badge (see apps/billboard/_partials/_invite_actions.html).
Post-sprint: that interaction surface migrates entirely onto bud.html —
the Line's prose becomes the interactive surface via an
`<a class="post-attribution">@<owner></a>` inside `span.post-line-text`
whose href routes to /billboard/buds/<owner_id>/. The bud.html page then
absorbs the accept/decline + spectator-link flow that used to live in the
Post.
Spec recap:
- When the bud has invited the user, a Brief slides down wherever the user
is on the site; FYI links to the @mailman Acceptances & rejections Post.
- The Post's mailman Line contains an `a.post-attribution` around the
inviter's handle, href=/billboard/buds/<inviter_id>/.
- The Line carries NO `.invite-actions` block (OK/BYE/Accepted/etc. removed).
- Following the post-attribution link auto-adds the inviter to the user's
buds list if not already a bud (verified separately in
test_bill_bud_page.py::BudPageAutoAddOnFirstVisitTest).
"""
from selenium.webdriver.common.by import By
from apps.billboard.mail import log_sea_invite
from apps.gameboard.models import SeaInvite
from apps.lyric.models import User, get_or_create_mailman
def _create_pending_invite(owner, invitee):
"""Reusable helper: alice → me PENDING SeaInvite + the @mailman Post +
Line + Brief log entry. Mirrors what gameboard.invite-bud would create
in real flow; calling log_sea_invite directly avoids dragging the
bud-invite POST view into the FT setup."""
inv = SeaInvite.objects.create(
owner=owner,
invitee_email=invitee.email,
invitee=invitee,
status=SeaInvite.PENDING,
)
log_sea_invite(inv)
return inv
from .base import FunctionalTest
class MailmanPostStructureTest(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")
get_or_create_mailman()
_create_pending_invite(self.alice, self.gamer)
self.create_pre_authenticated_session("me@test.io")
def _navigate_to_mailman_post(self):
"""Land on the user's @mailman post directly via the my_posts list.
Avoids hard-coding the post UUID."""
from apps.billboard.models import Post
post = Post.objects.get(owner=self.gamer, kind=Post.KIND_MAIL_ACCEPTANCE)
self.browser.get(
self.live_server_url + f"/billboard/post/{post.id}/"
)
def test_mailman_line_contains_post_attribution_anchor_to_bud_page(self):
"""span.post-line-text on the @mailman Line wraps the inviter's
handle in an <a class="post-attribution"> whose href is the bud
landing page for the inviter. Anchor text = the bud's at_handle
(e.g. '@alice'); the surrounding prose stays in plain text."""
self._navigate_to_mailman_post()
line = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".post-line--system"
))
anchor = line.find_element(
By.CSS_SELECTOR, "span.post-line-text a.post-attribution"
)
self.assertEqual(anchor.text, "@alice")
href = anchor.get_attribute("href")
self.assertIn(f"/billboard/buds/{self.alice.id}/", href)
def test_mailman_line_no_longer_renders_ok_bye_or_accepted_badge(self):
"""The pre-sprint .invite-actions block is GONE — the entire
accept/decline + accepted-date UX migrates onto bud.html. The Line
carries prose only."""
self._navigate_to_mailman_post()
line = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".post-line--system"
))
self.assertEqual(
line.find_elements(By.CSS_SELECTOR, ".invite-actions"),
[],
)
self.assertEqual(
line.find_elements(By.CSS_SELECTOR, ".invite-ok-btn"),
[],
)
self.assertEqual(
line.find_elements(By.CSS_SELECTOR, ".invite-bye-btn"),
[],
)
self.assertEqual(
line.find_elements(By.CSS_SELECTOR, ".invite-badge--accepted"),
[],
)
def test_mailman_line_no_invite_actions_remain_after_invite_accepted(self):
"""Even if the SeaInvite is in ACCEPTED state, the Line stays prose-
only — there's no longer an 'Accepted <date>' badge here (that
information surfaces on bud.html instead). Asserts the .invite-
actions sweep is unconditional, not just PENDING-only."""
SeaInvite.objects.filter(invitee=self.gamer).update(status=SeaInvite.ACCEPTED)
self._navigate_to_mailman_post()
line = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".post-line--system"
))
self.assertEqual(
line.find_elements(By.CSS_SELECTOR, ".invite-actions"),
[],
)
class MailmanBriefSurfacesOnAnyPageTest(FunctionalTest):
"""The Brief spawned by `log_sea_invite` should surface wherever the
user lands next — not only on the Post itself. Existing Brief delivery
is page-load based (banner injected by the server-rendered template +
note.js Brief.showBanner). The FT verifies the cross-page surface by
visiting an unrelated page first."""
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")
get_or_create_mailman()
_create_pending_invite(self.alice, self.gamer)
self.create_pre_authenticated_session("me@test.io")
def test_brief_appears_on_unrelated_page_load(self):
"""Land on `/` (not the Post itself). The Brief banner should
still be present — the invite spawned an unread Brief, which
the next page-load surfaces sitewide via the `mail_brief_
payload` context processor + base.html auto-showBanner."""
self.browser.get(self.live_server_url + "/")
banner = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".note-banner"
))
self.assertIn(
"Acceptances & rejections",
banner.find_element(By.CSS_SELECTOR, ".note-banner__title").text,
)
def test_brief_fyi_navigates_to_mailman_post(self):
"""FYI link on the slide-down banner is the cross-page nav into the
Acceptances & rejections Post. Click → land on the Post page."""
self.browser.get(self.live_server_url + "/")
fyi = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".note-banner__fyi"
))
fyi.click()
# Post URL pattern is /billboard/post/<uuid>/
self.wait_for(lambda: self.assertIn(
"/billboard/post/", self.browser.current_url
))
# And the page is the user's @mailman post (title visible).
self.wait_for(lambda: self.assertIn(
"Acceptances & rejections",
self.browser.find_element(By.CSS_SELECTOR, ".post-title").text,
))

View File

@@ -0,0 +1,140 @@
"""FT for the My Buds page — line rendering w. donned title + click-lock
tooltip portal + username <a> link to the per-bud landing page.
Companion to test_bill_my_buds.py (which covers Phase 1 add/render).
This file covers the My Buds enrichment that lands alongside the new
apps/billboard/bud.html landing page (see test_bill_bud_page.py).
Spec recap:
- Each bud row reads `@<handle> the <Title>` (at_handle + active_title_display).
- Clicking the row applies `.row-locked` to the `li.applet-list-entry` AND
opens the shared #id_tooltip_portal w. five fields:
.tt-title → `@<handle>`
.tt-description → bud's donned title ("Earthman" by default)
.tt-email → bud's email (small / italic / 0.75 opacity — CSS)
.tt-shoptalk → the user's personal note about this bud (160-char cap)
.tt-milestone → "edited <relative-ts>" (omitted when no edit yet)
- The `@<handle>` itself is an `<a>` element (--terUser by CSS) whose href
navigates to the bud's landing page /billboard/buds/<bud_id>/.
"""
from selenium.webdriver.common.by import By
from apps.lyric.models import User
from .base import FunctionalTest
class MyBudsLineRendersBudTitleTest(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.gamer.buds.add(self.alice)
self.create_pre_authenticated_session("me@test.io")
def test_bud_entry_reads_at_handle_the_title(self):
"""Default (no donned Note) → '@alice the Earthman' — active_title_display
falls back to 'Earthman' when the bud hasn't donned any Note title.
Renders inside .bud-entry; the username span carries the `.bud-name`
class for backward compat w. test_bill_my_buds.py."""
self.browser.get(self.live_server_url + "/billboard/my-buds/")
entry = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, f".bud-entry[data-bud-id='{self.alice.id}']"
))
# Full row text combines handle + title
self.assertIn("@alice the Earthman", entry.text)
class MyBudsClickOpensTooltipPortalTest(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.gamer.buds.add(self.alice)
self.create_pre_authenticated_session("me@test.io")
def test_click_locks_row_and_opens_tooltip_portal(self):
"""Click the bud row → li picks up .row-locked AND the shared
#id_tooltip_portal becomes .active w. the bud's data populated
into .tt-title / .tt-description / .tt-email."""
self.browser.get(self.live_server_url + "/billboard/my-buds/")
row = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, f".bud-entry[data-bud-id='{self.alice.id}']"
))
self.assertNotIn("row-locked", row.get_attribute("class") or "")
row.click()
# Row picks up .row-locked
self.wait_for(lambda: self.assertIn(
"row-locked", row.get_attribute("class") or ""
))
# Portal slides in active
portal = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tooltip_portal.active"
))
# Field assertions — text() suffices; the SCSS rules (italic,
# opacity, glow) are out of FT scope.
title = portal.find_element(By.CSS_SELECTOR, ".tt-title").text
desc = portal.find_element(By.CSS_SELECTOR, ".tt-description").text
email = portal.find_element(By.CSS_SELECTOR, ".tt-email").text
self.assertEqual(title, "@alice")
self.assertEqual(desc, "Earthman")
self.assertEqual(email, "alice@test.io")
def test_tooltip_milestone_absent_when_shoptalk_never_edited(self):
"""`.tt-milestone` renders only when the user has edited the shoptalk
for this bud — fresh bud has no BudshipNote row yet, so no edit
timestamp exists. The cell should be ABSENT (not empty)."""
self.browser.get(self.live_server_url + "/billboard/my-buds/")
row = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, f".bud-entry[data-bud-id='{self.alice.id}']"
))
row.click()
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tooltip_portal.active"
))
self.assertEqual(
self.browser.find_elements(By.CSS_SELECTOR, "#id_tooltip_portal .tt-milestone"),
[],
)
def test_tooltip_shoptalk_empty_string_when_never_edited(self):
"""Shoptalk slot still renders (so the layout slot exists), just
with empty body. Distinguishes never-edited (slot empty) from
cleared-after-edit (slot empty + .tt-milestone present)."""
self.browser.get(self.live_server_url + "/billboard/my-buds/")
self.browser.find_element(
By.CSS_SELECTOR, f".bud-entry[data-bud-id='{self.alice.id}']"
).click()
portal = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tooltip_portal.active"
))
# .tt-shoptalk exists but is empty
shoptalk = portal.find_element(By.CSS_SELECTOR, ".tt-shoptalk")
self.assertEqual(shoptalk.text.strip(), "")
class MyBudsUsernameAnchorTest(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.gamer.buds.add(self.alice)
self.create_pre_authenticated_session("me@test.io")
def test_username_inside_row_is_anchor_to_bud_page(self):
"""The `@alice` text itself is wrapped in an `<a>` element (--terUser
per global SCSS); href routes to /billboard/buds/<bud_id>/. The row
wrapper still receives clicks for the row-lock + tooltip flow (the
anchor is a CHILD of the bud-name span; clicking the row outside
the anchor locks; clicking the anchor itself navigates)."""
self.browser.get(self.live_server_url + "/billboard/my-buds/")
anchor = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR,
f".bud-entry[data-bud-id='{self.alice.id}'] .bud-name a",
))
self.assertEqual(anchor.text, "@alice")
href = anchor.get_attribute("href")
self.assertIn(f"/billboard/buds/{self.alice.id}/", href)