bud landing page: /billboard/buds/<id>/ + my_buds tooltip portal + @mailman post-attribution anchor — TDD
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:
276
src/functional_tests/test_bill_bud_page.py
Normal file
276
src/functional_tests/test_bill_bud_page.py
Normal 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()))
|
||||
168
src/functional_tests/test_bill_mailman_invite_post.py
Normal file
168
src/functional_tests/test_bill_mailman_invite_post.py
Normal 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,
|
||||
))
|
||||
140
src/functional_tests/test_bill_my_buds_tooltip.py
Normal file
140
src/functional_tests/test_bill_my_buds_tooltip.py
Normal 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)
|
||||
Reference in New Issue
Block a user