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:
@@ -29,12 +29,18 @@ from apps.lyric.models import get_or_create_mailman, resolve_pronouns
|
||||
from apps.lyric.templatetags.lyric_extras import at_handle
|
||||
|
||||
|
||||
# Invite prose shown to the invitee. `{handle}` = the owner's `@username`
|
||||
# (via at_handle — falls back to truncated email for handle-less owners);
|
||||
# Invite prose shown to the invitee. `{handle}` is wrapped in an
|
||||
# `<a class="post-attribution">` whose href routes to the owner's per-bud
|
||||
# landing page — bud landing page sprint 2026-05-27 replaced the in-Line
|
||||
# OK/BYE form-button block w. this navigational anchor. post.html's
|
||||
# `safe`-filter branch is gated on `line.author.username == 'mailman'`
|
||||
# (alongside 'adman'/'taxman') so the anchor renders as HTML.
|
||||
#
|
||||
# `{poss}` = the owner's possessive pronoun ("their"/"his"/"her"/…), so the
|
||||
# table reads as the owner's. Em dash matches the @taxman "Look!—" house style.
|
||||
INVITE_TEMPLATE = (
|
||||
"Listen!—{handle} invites you to {poss} drawing table. "
|
||||
'Listen!—<a class="post-attribution" href="/billboard/buds/{owner_id}/">{handle}</a>'
|
||||
" invites you to {poss} drawing table. "
|
||||
"This invite will expire 24h from the time it was extended."
|
||||
)
|
||||
|
||||
@@ -67,6 +73,7 @@ def log_sea_invite(sea_invite):
|
||||
post.save(update_fields=["title"])
|
||||
|
||||
text = INVITE_TEMPLATE.format(
|
||||
owner_id=owner.id,
|
||||
handle=at_handle(owner),
|
||||
poss=resolve_pronouns(owner.pronouns)[2],
|
||||
)
|
||||
|
||||
30
src/apps/billboard/migrations/0010_budshipnote.py
Normal file
30
src/apps/billboard/migrations/0010_budshipnote.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 6.0 on 2026-05-28 15:12
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billboard', '0009_alter_brief_kind_alter_post_kind'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BudshipNote',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('shoptalk', models.CharField(default='', max_length=160)),
|
||||
('edited_at', models.DateTimeField(auto_now=True)),
|
||||
('bud', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budship_notes_about', to=settings.AUTH_USER_MODEL)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budship_notes_written', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-edited_at',),
|
||||
'unique_together': {('user', 'bud')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -260,3 +260,33 @@ def _delete_unsolicited_admin_post_lines(sender, instance, created, **kwargs):
|
||||
return
|
||||
if instance.post.kind in _SYSTEM_AUTHOR_POST_KINDS and not instance.admin_solicited:
|
||||
instance.delete()
|
||||
|
||||
|
||||
class BudshipNote(models.Model):
|
||||
"""Per-relation personal note about a bud — bud landing page sprint
|
||||
2026-05-27 ([[project-bud-landing-page-sprint]]). One row per
|
||||
(user, bud) pair: the user's own shoptalk about that bud, NEVER
|
||||
visible to the bud. Lazy-created on first shoptalk save so the
|
||||
absence of a row reads as 'never edited' (drives the `.tt-milestone`
|
||||
slot on the My Buds tooltip portal — present when ≥1 edit, absent
|
||||
otherwise)."""
|
||||
|
||||
user = models.ForeignKey(
|
||||
"lyric.User",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="budship_notes_written",
|
||||
)
|
||||
bud = models.ForeignKey(
|
||||
"lyric.User",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="budship_notes_about",
|
||||
)
|
||||
shoptalk = models.CharField(max_length=160, default="")
|
||||
edited_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("user", "bud")
|
||||
ordering = ("-edited_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"BudshipNote({self.user_id} → {self.bud_id})"
|
||||
|
||||
103
src/apps/billboard/static/apps/billboard/my-buds-tooltip.js
Normal file
103
src/apps/billboard/static/apps/billboard/my-buds-tooltip.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// Row-click → row-lock + tooltip-portal for the My Buds list.
|
||||
//
|
||||
// Bud landing page sprint 2026-05-27 ([[project-bud-landing-page-sprint]]).
|
||||
// Mirrors apps/applets/row-lock.js's lock/unlock state machine, but binds
|
||||
// to `.bud-entry` rows (which are NOT `.row-3col`) AND populates the
|
||||
// shared `#id_tooltip_portal` from the row's data-tt-* attrs.
|
||||
//
|
||||
// Click target semantics:
|
||||
// • Click on the inner `<a>` (the `@<handle>` anchor) — let it navigate
|
||||
// to the bud's landing page. NO lock fires.
|
||||
// • Click anywhere else inside `.bud-entry` — lock the row + open the
|
||||
// tooltip portal w. its data-tt-* fields populated.
|
||||
// • Click outside any `.bud-entry` (and outside the portal) — clear.
|
||||
//
|
||||
// `.tt-milestone` is REMOVED from the DOM (vs. emptied) when the row's
|
||||
// `data-tt-milestone` attr is absent so the FT can distinguish "never
|
||||
// edited" (slot absent) from "cleared after edit" (slot empty).
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var _lockedRow = null;
|
||||
var _portal = null;
|
||||
var _milestoneTemplate = null;
|
||||
|
||||
function _clearLock() {
|
||||
if (_lockedRow) {
|
||||
_lockedRow.classList.remove('row-locked');
|
||||
_lockedRow = null;
|
||||
}
|
||||
if (_portal) _portal.classList.remove('active');
|
||||
}
|
||||
|
||||
function _findSlot(name) {
|
||||
if (!_portal) return null;
|
||||
return _portal.querySelector('.tt-' + name);
|
||||
}
|
||||
|
||||
function _populatePortal(row) {
|
||||
if (!_portal) return;
|
||||
var fields = ['title', 'description', 'email', 'shoptalk'];
|
||||
fields.forEach(function (f) {
|
||||
var slot = _findSlot(f);
|
||||
if (slot) slot.textContent = row.dataset['tt' + f.charAt(0).toUpperCase() + f.slice(1)] || '';
|
||||
});
|
||||
// .tt-milestone — absent when never-edited; present (+ populated)
|
||||
// when the row carries data-tt-milestone.
|
||||
var ms = row.dataset.ttMilestone;
|
||||
var existing = _findSlot('milestone');
|
||||
if (ms) {
|
||||
if (!existing) {
|
||||
var span = _milestoneTemplate.cloneNode(true);
|
||||
span.textContent = ms;
|
||||
_portal.appendChild(span);
|
||||
} else {
|
||||
existing.textContent = ms;
|
||||
}
|
||||
} else {
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function _onClick(e) {
|
||||
// Anchor click — let navigation proceed; no lock/portal.
|
||||
if (e.target.closest('.bud-entry .bud-name a')) {
|
||||
_clearLock();
|
||||
return;
|
||||
}
|
||||
var row = e.target.closest('.bud-entry');
|
||||
if (row) {
|
||||
if (row === _lockedRow) {
|
||||
_clearLock();
|
||||
} else {
|
||||
_clearLock();
|
||||
row.classList.add('row-locked');
|
||||
_lockedRow = row;
|
||||
_populatePortal(row);
|
||||
if (_portal) _portal.classList.add('active');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Click inside the portal itself — preserve lock.
|
||||
if (_portal && _portal.contains(e.target)) return;
|
||||
_clearLock();
|
||||
}
|
||||
|
||||
function _init() {
|
||||
_portal = document.getElementById('id_tooltip_portal');
|
||||
if (_portal) {
|
||||
// Snapshot the milestone element shape so we can restore it
|
||||
// after a never-edited row removes it.
|
||||
var ms = _portal.querySelector('.tt-milestone');
|
||||
if (ms) _milestoneTemplate = ms.cloneNode(false);
|
||||
}
|
||||
document.addEventListener('click', _onClick);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _init);
|
||||
} else {
|
||||
_init();
|
||||
}
|
||||
}());
|
||||
@@ -114,6 +114,19 @@ class LogSeaInviteTest(TestCase):
|
||||
def test_template_uses_listen_hook(self):
|
||||
self.assertTrue(INVITE_TEMPLATE.startswith("Listen!"))
|
||||
|
||||
def test_line_wraps_owner_handle_in_post_attribution_anchor(self):
|
||||
"""Bud landing page sprint 2026-05-27 — the inline OK/BYE block
|
||||
migrates onto bud.html; the @mailman Line now carries an
|
||||
`<a class="post-attribution">` around the owner's handle whose
|
||||
href routes to the owner's per-bud landing page."""
|
||||
_, line, _ = log_sea_invite(self.invite)
|
||||
self.assertIn('class="post-attribution"', line.text)
|
||||
self.assertIn(
|
||||
f'href="/billboard/buds/{self.owner.id}/"', line.text,
|
||||
)
|
||||
# The anchor wraps ONLY the at_handle (not the surrounding prose)
|
||||
self.assertIn(">@discoman</a>", line.text)
|
||||
|
||||
|
||||
class MailAcceptanceKindTest(TestCase):
|
||||
"""The new MAIL_ACCEPTANCE kind is registered on both Post + Brief."""
|
||||
|
||||
@@ -1140,3 +1140,350 @@ class BillboardAppletMySignTest(TestCase):
|
||||
# Middle court has a suit, so the suit-icon `<i>` is present + carries
|
||||
# the canonical FA class for the suit (fa-wand-sparkles for BRANDS etc).
|
||||
self.assertTrue(any(cls.startswith("fa-") for cls in (icon.get("class") or "").split()))
|
||||
|
||||
|
||||
# ── Per-bud Landing Page ─────────────────────────────────────────────────
|
||||
# /billboard/buds/<uuid:bud_id>/ + the my_buds row enrichment that surfaces
|
||||
# the new tooltip-portal data — bud landing page sprint 2026-05-27 (see
|
||||
# [[project-bud-landing-page-sprint]]). Replaces the @mailman invite Line's
|
||||
# inline OK/BYE block w. a dedicated page; the My Buds list rows now wrap
|
||||
# the `@<handle>` in an anchor to the bud's page + carry data-tt-* attrs
|
||||
# the JS portal reads on row-lock click.
|
||||
|
||||
|
||||
class BudPageRenderTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="me@buds.io", username="me")
|
||||
self.alice = User.objects.create(email="alice@buds.io", username="alice")
|
||||
self.user.buds.add(self.alice)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_returns_200(self):
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_uses_bud_template(self):
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertTemplateUsed(response, "apps/billboard/bud.html")
|
||||
|
||||
def test_passes_bud_in_context(self):
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertEqual(response.context["bud"], self.alice)
|
||||
|
||||
def test_passes_empty_shoptalk_when_no_note(self):
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertEqual(response.context["shoptalk_text"], "")
|
||||
self.assertIsNone(response.context["milestone_dt"])
|
||||
|
||||
def test_header_renders_at_handle_the_title_and_email(self):
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
body = response.content.decode()
|
||||
self.assertIn("@alice", body)
|
||||
self.assertIn("the Earthman", body)
|
||||
self.assertIn("alice@buds.io", body)
|
||||
|
||||
def test_shoptalk_textarea_carries_160_char_maxlength(self):
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
body = response.content.decode()
|
||||
self.assertRegex(
|
||||
body, r'<textarea[^>]+id="id_shoptalk"[^>]*maxlength="160"',
|
||||
)
|
||||
|
||||
def test_existing_shoptalk_renders_in_textarea(self):
|
||||
from apps.billboard.models import BudshipNote
|
||||
BudshipNote.objects.create(
|
||||
user=self.user, bud=self.alice, shoptalk="loves chess",
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertEqual(response.context["shoptalk_text"], "loves chess")
|
||||
self.assertIsNotNone(response.context["milestone_dt"])
|
||||
self.assertContains(response, "loves chess")
|
||||
|
||||
|
||||
class BudPageAutoAddOnFirstVisitTest(TestCase):
|
||||
"""Visiting bud.html for a non-bud auto-adds them to the user's buds —
|
||||
mirrors share_post's implicit-add posture so the @mailman post-
|
||||
attribution anchor lands the inviter on the user's buds graph."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="me@auto.io", username="me")
|
||||
self.alice = User.objects.create(email="alice@auto.io", username="alice")
|
||||
# alice is NOT in user.buds — auto-add is the contract
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_visit_adds_bud_to_m2m(self):
|
||||
self.assertNotIn(self.alice, list(self.user.buds.all()))
|
||||
self.client.get(reverse("billboard:bud_page", args=[self.alice.id]))
|
||||
self.assertIn(self.alice, list(self.user.buds.all()))
|
||||
|
||||
def test_self_visit_does_not_self_add(self):
|
||||
# Pathological case: navigating to your own bud page must not seed
|
||||
# the user as their own bud (M2M is asymmetric self-FK).
|
||||
self.client.get(reverse("billboard:bud_page", args=[self.user.id]))
|
||||
self.assertNotIn(self.user, list(self.user.buds.all()))
|
||||
|
||||
def test_already_bud_visit_is_idempotent(self):
|
||||
self.user.buds.add(self.alice)
|
||||
self.client.get(reverse("billboard:bud_page", args=[self.alice.id]))
|
||||
# M2M dedup'd; still one row
|
||||
self.assertEqual(self.user.buds.filter(pk=self.alice.pk).count(), 1)
|
||||
|
||||
|
||||
class BudPagePendingInviteCascadeTest(TestCase):
|
||||
"""`sea_btn_active` + `sea_first_draw_pending` fire iff a non-expired
|
||||
PENDING SeaInvite exists from this bud (owner) to the viewer (invitee).
|
||||
Reuses the same template flags `_burger.html` already reads on my_sea
|
||||
+ room — no new template plumbing on bud.html."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.gameboard.models import SeaInvite
|
||||
self.SeaInvite = SeaInvite
|
||||
self.user = User.objects.create(email="me@inv.io", username="me")
|
||||
self.alice = User.objects.create(email="alice@inv.io", username="alice")
|
||||
self.user.buds.add(self.alice)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_no_invite_no_cascade(self):
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertIsNone(response.context["pending_invite"])
|
||||
self.assertFalse(response.context["sea_btn_active"])
|
||||
self.assertFalse(response.context["sea_first_draw_pending"])
|
||||
|
||||
def test_pending_invite_lights_cascade(self):
|
||||
self.SeaInvite.objects.create(
|
||||
owner=self.alice,
|
||||
invitee=self.user,
|
||||
invitee_email=self.user.email,
|
||||
status=self.SeaInvite.PENDING,
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertIsNotNone(response.context["pending_invite"])
|
||||
self.assertTrue(response.context["sea_btn_active"])
|
||||
self.assertTrue(response.context["sea_first_draw_pending"])
|
||||
|
||||
def test_accepted_invite_does_not_cascade(self):
|
||||
self.SeaInvite.objects.create(
|
||||
owner=self.alice,
|
||||
invitee=self.user,
|
||||
invitee_email=self.user.email,
|
||||
status=self.SeaInvite.ACCEPTED,
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertIsNone(response.context["pending_invite"])
|
||||
self.assertFalse(response.context["sea_btn_active"])
|
||||
|
||||
def test_expired_pending_invite_does_not_cascade(self):
|
||||
inv = self.SeaInvite.objects.create(
|
||||
owner=self.alice,
|
||||
invitee=self.user,
|
||||
invitee_email=self.user.email,
|
||||
status=self.SeaInvite.PENDING,
|
||||
)
|
||||
self.SeaInvite.objects.filter(pk=inv.pk).update(
|
||||
created_at=timezone.now() - timezone.timedelta(hours=48),
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertIsNone(response.context["pending_invite"])
|
||||
self.assertFalse(response.context["sea_btn_active"])
|
||||
|
||||
def test_invite_for_other_invitee_ignored(self):
|
||||
# Pending invite from alice → some other user is irrelevant to ME.
|
||||
other = User.objects.create(email="other@inv.io", username="other")
|
||||
self.SeaInvite.objects.create(
|
||||
owner=self.alice,
|
||||
invitee=other,
|
||||
invitee_email=other.email,
|
||||
status=self.SeaInvite.PENDING,
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("billboard:bud_page", args=[self.alice.id])
|
||||
)
|
||||
self.assertIsNone(response.context["pending_invite"])
|
||||
|
||||
|
||||
class SaveBudShoptalkViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="me@sav.io", username="me")
|
||||
self.alice = User.objects.create(email="alice@sav.io", username="alice")
|
||||
self.user.buds.add(self.alice)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_post_creates_budship_note(self):
|
||||
from apps.billboard.models import BudshipNote
|
||||
self.client.post(
|
||||
reverse("billboard:save_bud_shoptalk", args=[self.alice.id]),
|
||||
{"shoptalk": "first thoughts"},
|
||||
)
|
||||
bn = BudshipNote.objects.get(user=self.user, bud=self.alice)
|
||||
self.assertEqual(bn.shoptalk, "first thoughts")
|
||||
|
||||
def test_post_updates_existing_budship_note(self):
|
||||
from apps.billboard.models import BudshipNote
|
||||
BudshipNote.objects.create(user=self.user, bud=self.alice, shoptalk="old")
|
||||
self.client.post(
|
||||
reverse("billboard:save_bud_shoptalk", args=[self.alice.id]),
|
||||
{"shoptalk": "new"},
|
||||
)
|
||||
bn = BudshipNote.objects.get(user=self.user, bud=self.alice)
|
||||
self.assertEqual(bn.shoptalk, "new")
|
||||
|
||||
def test_post_caps_at_160_chars(self):
|
||||
from apps.billboard.models import BudshipNote
|
||||
self.client.post(
|
||||
reverse("billboard:save_bud_shoptalk", args=[self.alice.id]),
|
||||
{"shoptalk": "a" * 300},
|
||||
)
|
||||
bn = BudshipNote.objects.get(user=self.user, bud=self.alice)
|
||||
self.assertLessEqual(len(bn.shoptalk), 160)
|
||||
|
||||
def test_get_returns_405(self):
|
||||
response = self.client.get(
|
||||
reverse("billboard:save_bud_shoptalk", args=[self.alice.id])
|
||||
)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.post(
|
||||
reverse("billboard:save_bud_shoptalk", args=[self.alice.id]),
|
||||
{"shoptalk": "anon"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
|
||||
class DeleteBudViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="me@del.io", username="me")
|
||||
self.alice = User.objects.create(email="alice@del.io", username="alice")
|
||||
self.user.buds.add(self.alice)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_post_removes_bud_from_m2m(self):
|
||||
self.client.post(
|
||||
reverse("billboard:delete_bud", args=[self.alice.id])
|
||||
)
|
||||
self.assertNotIn(self.alice, list(self.user.buds.all()))
|
||||
|
||||
def test_post_redirects_to_my_buds(self):
|
||||
response = self.client.post(
|
||||
reverse("billboard:delete_bud", args=[self.alice.id])
|
||||
)
|
||||
self.assertRedirects(response, reverse("billboard:my_buds"))
|
||||
|
||||
def test_get_does_not_remove(self):
|
||||
self.client.get(reverse("billboard:delete_bud", args=[self.alice.id]))
|
||||
self.assertIn(self.alice, list(self.user.buds.all()))
|
||||
|
||||
|
||||
class MyBudsRowEnrichmentTest(TestCase):
|
||||
"""The my_buds page row now carries the data-tt-* attrs the tooltip
|
||||
portal reads on row-lock click, plus an anchor wrapping the handle
|
||||
that routes to the bud's landing page."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="me@row.io", username="me")
|
||||
self.alice = User.objects.create(email="alice@row.io", username="alice")
|
||||
self.user.buds.add(self.alice)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_row_carries_data_bud_id(self):
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
self.assertContains(response, f'data-bud-id="{self.alice.id}"')
|
||||
|
||||
def test_row_carries_tt_title_description_email_attrs(self):
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
self.assertContains(response, 'data-tt-title="@alice"')
|
||||
self.assertContains(response, 'data-tt-description="Earthman"')
|
||||
self.assertContains(response, 'data-tt-email="alice@row.io"')
|
||||
|
||||
def test_row_renders_at_handle_the_title(self):
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
body = response.content.decode()
|
||||
self.assertIn("@alice", body)
|
||||
self.assertIn("the Earthman", body)
|
||||
|
||||
def test_username_wrapped_in_anchor_to_bud_page(self):
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
body = response.content.decode()
|
||||
bud_page_url = reverse("billboard:bud_page", args=[self.alice.id])
|
||||
self.assertRegex(
|
||||
body,
|
||||
rf'<span class="bud-name"><a[^>]*href="{bud_page_url}"',
|
||||
)
|
||||
|
||||
def test_row_carries_shoptalk_when_set(self):
|
||||
from apps.billboard.models import BudshipNote
|
||||
BudshipNote.objects.create(
|
||||
user=self.user, bud=self.alice, shoptalk="dragonkin",
|
||||
)
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
self.assertContains(response, 'data-tt-shoptalk="dragonkin"')
|
||||
self.assertContains(response, "data-tt-milestone=")
|
||||
|
||||
def test_row_carries_empty_shoptalk_attr_when_never_edited(self):
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
self.assertContains(response, 'data-tt-shoptalk=""')
|
||||
|
||||
def test_row_omits_milestone_when_no_note(self):
|
||||
response = self.client.get(reverse("billboard:my_buds"))
|
||||
body = response.content.decode()
|
||||
self.assertNotIn("data-tt-milestone=", body)
|
||||
|
||||
|
||||
class BudshipNoteModelTest(TestCase):
|
||||
"""`BudshipNote(user, bud, shoptalk, edited_at)` — per-relation note."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="me@m.io", username="me")
|
||||
self.bud = User.objects.create(email="b@m.io", username="b")
|
||||
|
||||
def test_unique_per_user_bud_pair(self):
|
||||
from django.db import IntegrityError
|
||||
from apps.billboard.models import BudshipNote
|
||||
BudshipNote.objects.create(user=self.user, bud=self.bud, shoptalk="x")
|
||||
with self.assertRaises(IntegrityError):
|
||||
BudshipNote.objects.create(user=self.user, bud=self.bud, shoptalk="y")
|
||||
|
||||
def test_edited_at_updates_on_save(self):
|
||||
from apps.billboard.models import BudshipNote
|
||||
bn = BudshipNote.objects.create(
|
||||
user=self.user, bud=self.bud, shoptalk="first",
|
||||
)
|
||||
first_ts = bn.edited_at
|
||||
bn.shoptalk = "second"
|
||||
bn.save()
|
||||
self.assertGreaterEqual(bn.edited_at, first_ts)
|
||||
|
||||
def test_shoptalk_max_length_160(self):
|
||||
from apps.billboard.models import BudshipNote
|
||||
f = BudshipNote._meta.get_field("shoptalk")
|
||||
self.assertEqual(f.max_length, 160)
|
||||
|
||||
@@ -23,6 +23,9 @@ urlpatterns = [
|
||||
path("my-buds/", views.my_buds, name="my_buds"),
|
||||
path("buds/add", views.add_bud, name="add_bud"),
|
||||
path("buds/search", views.search_buds, name="search_buds"),
|
||||
path("buds/<uuid:bud_id>/", views.bud_page, name="bud_page"),
|
||||
path("buds/<uuid:bud_id>/shoptalk", views.save_bud_shoptalk, name="save_bud_shoptalk"),
|
||||
path("buds/<uuid:bud_id>/delete", views.delete_bud, name="delete_bud"),
|
||||
path("my-sign/", views.my_sign, name="my_sign"),
|
||||
path("my-sign/save", views.save_sign, name="save_sign"),
|
||||
path("my-sign/clear", views.clear_sign, name="clear_sign"),
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.shortcuts import redirect, render
|
||||
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||
|
||||
from apps.billboard.forms import ExistingPostLineForm, LineForm
|
||||
from apps.billboard.models import Brief, Line, Post
|
||||
from apps.billboard.models import Brief, BudshipNote, Line, Post
|
||||
from apps.dashboard.views import _PALETTE_DEFS
|
||||
from apps.drama.models import GameEvent, Note, ScrollPosition
|
||||
from apps.epic.models import Room
|
||||
@@ -567,12 +567,96 @@ def share_post(request, post_id):
|
||||
|
||||
@login_required(login_url="/")
|
||||
def my_buds(request):
|
||||
"""My Buds page — enriched per-row w. shoptalk + milestone for the
|
||||
tooltip portal (bud landing page sprint 2026-05-27). Attaches
|
||||
`.shoptalk_text` + `.milestone_dt` to each bud User so the row
|
||||
template can render data-tt-* attrs without an extra template tag."""
|
||||
notes_by_bud = {
|
||||
bn.bud_id: bn
|
||||
for bn in BudshipNote.objects.filter(user=request.user)
|
||||
}
|
||||
buds = list(request.user.buds.all().select_related("active_title"))
|
||||
for bud in buds:
|
||||
bn = notes_by_bud.get(bud.id)
|
||||
bud.shoptalk_text = bn.shoptalk if bn else ""
|
||||
bud.milestone_dt = bn.edited_at if bn else None
|
||||
return render(request, "apps/billboard/my_buds.html", {
|
||||
"buds": request.user.buds.all(),
|
||||
"buds": buds,
|
||||
"page_class": "page-billbuds",
|
||||
})
|
||||
|
||||
|
||||
# ── Per-bud landing page ───────────────────────────────────────────────────
|
||||
# /billboard/buds/<bud_id>/ + shoptalk save + bud delete — see
|
||||
# [[project-bud-landing-page-sprint]]. Replaces the @mailman invite Line's
|
||||
# inline OK/BYE block w. a dedicated surface; bud.html is also the click
|
||||
# target of the My Buds row's `@<handle>` anchor.
|
||||
|
||||
@login_required(login_url="/")
|
||||
def bud_page(request, bud_id):
|
||||
"""Render the per-bud landing page. Auto-adds the bud on first visit
|
||||
(mirrors share_post's implicit-add posture) so following the @mailman
|
||||
post-attribution anchor from an invite Brief grows the buds graph
|
||||
without an explicit add step. Self-visits are no-op for the auto-add
|
||||
branch — users don't accumulate themselves as a bud.
|
||||
|
||||
Cascade context (`sea_btn_active` + `sea_first_draw_pending`) reuses
|
||||
the same template variables `_burger.html` already reads on my_sea +
|
||||
room — server-side conditional renders `glow-handoff` on the burger
|
||||
+ `.active` on the sea sub-btn. The flags fire iff a non-expired
|
||||
PENDING SeaInvite exists from this bud to the viewer."""
|
||||
from django.shortcuts import get_object_or_404
|
||||
from apps.gameboard.models import SeaInvite
|
||||
bud = get_object_or_404(User, id=bud_id)
|
||||
if bud != request.user and not request.user.buds.filter(id=bud.id).exists():
|
||||
request.user.buds.add(bud)
|
||||
bn = BudshipNote.objects.filter(user=request.user, bud=bud).first()
|
||||
pending = (
|
||||
SeaInvite.objects
|
||||
.filter(owner=bud, invitee=request.user, status=SeaInvite.PENDING)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
if pending is not None and pending.is_expired:
|
||||
pending = None
|
||||
return render(request, "apps/billboard/bud.html", {
|
||||
"bud": bud,
|
||||
"shoptalk_text": bn.shoptalk if bn else "",
|
||||
"milestone_dt": bn.edited_at if bn else None,
|
||||
"pending_invite": pending,
|
||||
"sea_btn_active": pending is not None,
|
||||
"sea_first_draw_pending": pending is not None,
|
||||
"page_class": "page-billbud",
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def save_bud_shoptalk(request, bud_id):
|
||||
"""POST-only — upsert a BudshipNote w. up to 160 chars of shoptalk."""
|
||||
from django.http import HttpResponseNotAllowed
|
||||
from django.shortcuts import get_object_or_404
|
||||
if request.method != "POST":
|
||||
return HttpResponseNotAllowed(["POST"])
|
||||
bud = get_object_or_404(User, id=bud_id)
|
||||
text = (request.POST.get("shoptalk") or "")[:160]
|
||||
BudshipNote.objects.update_or_create(
|
||||
user=request.user, bud=bud,
|
||||
defaults={"shoptalk": text},
|
||||
)
|
||||
return JsonResponse({"ok": True, "shoptalk": text})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def delete_bud(request, bud_id):
|
||||
"""POST-only — remove the bud from the user's M2M; redirect to my_buds.
|
||||
GET is a silent no-op redirect (no membership change)."""
|
||||
from django.shortcuts import get_object_or_404
|
||||
if request.method == "POST":
|
||||
bud = get_object_or_404(User, id=bud_id)
|
||||
request.user.buds.remove(bud)
|
||||
return redirect("billboard:my_buds")
|
||||
|
||||
|
||||
def _resolve_recipient(raw):
|
||||
"""Resolve a free-form recipient (email OR username) to a User, or None.
|
||||
Email match takes precedence — if the input contains '@' we don't even
|
||||
|
||||
@@ -138,8 +138,11 @@ class MySeaInviteAcceptDeclineTest(TestCase):
|
||||
|
||||
|
||||
class MySeaInvitePostRenderTest(TestCase):
|
||||
"""post.html (A5) — the @mailman invite Line renders OK/BYE for PENDING +
|
||||
a status badge otherwise, all driven by `line.sea_invite.status`."""
|
||||
"""post.html — the @mailman invite Line carries a post-attribution
|
||||
anchor around the owner's handle whose href routes to the owner's
|
||||
per-bud landing page. Bud landing page sprint 2026-05-27 ([[project-
|
||||
bud-landing-page-sprint]]) migrated the prior inline OK/BYE/Accepted
|
||||
block onto bud.html; this class pins the new prose contract."""
|
||||
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create(email="owner@test.io", username="discoman")
|
||||
@@ -154,26 +157,31 @@ class MySeaInvitePostRenderTest(TestCase):
|
||||
self.accept_url = reverse("my_sea_invite_accept", args=[self.invite.id])
|
||||
self.decline_url = reverse("my_sea_invite_decline", args=[self.invite.id])
|
||||
|
||||
def test_pending_invite_renders_ok_bye(self):
|
||||
def test_pending_invite_renders_post_attribution_anchor_not_buttons(self):
|
||||
content = self.client.get(self.post_url).content.decode()
|
||||
self.assertIn("invite-ok-btn", content)
|
||||
self.assertIn("invite-bye-btn", content)
|
||||
self.assertIn(self.accept_url, content)
|
||||
self.assertIn(self.decline_url, content)
|
||||
# OK/BYE buttons + accept/decline form actions migrated onto bud.html.
|
||||
self.assertNotIn("invite-ok-btn", content)
|
||||
self.assertNotIn("invite-bye-btn", content)
|
||||
self.assertNotIn(self.accept_url, content)
|
||||
self.assertNotIn(self.decline_url, content)
|
||||
# Anchor wraps the owner's handle, routing to their bud landing page.
|
||||
self.assertIn('class="post-attribution"', content)
|
||||
self.assertIn(f"/billboard/buds/{self.owner.id}/", content)
|
||||
|
||||
def test_accepted_invite_renders_badge_not_buttons(self):
|
||||
def test_accepted_invite_renders_no_badge(self):
|
||||
self.invite.status = SeaInvite.ACCEPTED
|
||||
self.invite.accepted_at = timezone.now()
|
||||
self.invite.save()
|
||||
content = self.client.get(self.post_url).content.decode()
|
||||
self.assertIn("Accepted", content)
|
||||
# No "Accepted <date>" badge — the sweep is unconditional.
|
||||
self.assertNotIn("invite-badge--accepted", content)
|
||||
self.assertNotIn(self.accept_url, content)
|
||||
|
||||
def test_declined_invite_renders_declined_badge(self):
|
||||
def test_declined_invite_renders_no_badge(self):
|
||||
self.invite.status = SeaInvite.DECLINED
|
||||
self.invite.save()
|
||||
content = self.client.get(self.post_url).content.decode()
|
||||
self.assertIn("Declined", content)
|
||||
self.assertNotIn("invite-badge--declined", content)
|
||||
self.assertNotIn(self.accept_url, content)
|
||||
|
||||
def test_mailman_line_renders_as_system_with_handles(self):
|
||||
|
||||
@@ -4,6 +4,35 @@ def user_palette(request):
|
||||
return {"user_palette": "palette-default"}
|
||||
|
||||
|
||||
def mail_brief_payload(request):
|
||||
"""Inject the user's oldest unread @mailman "Acceptances & rejections"
|
||||
Brief into every authenticated response context — bud landing page
|
||||
sprint 2026-05-27 ([[project-bud-landing-page-sprint]]). The base
|
||||
template renders the payload as a JSON script + auto-fires
|
||||
Brief.showBanner so the slide-down notification surfaces on the next
|
||||
page load regardless of where the invitee lands.
|
||||
|
||||
Marked read by `view_post`'s existing unread-flip on GET of the
|
||||
underlying MAIL_ACCEPTANCE Post — same mark-read contract every other
|
||||
Brief kind already uses."""
|
||||
if not request.user.is_authenticated:
|
||||
return {}
|
||||
from apps.billboard.models import Brief
|
||||
brief = (
|
||||
Brief.objects
|
||||
.filter(
|
||||
owner=request.user,
|
||||
kind=Brief.KIND_MAIL_ACCEPTANCE,
|
||||
is_unread=True,
|
||||
)
|
||||
.order_by("created_at")
|
||||
.first()
|
||||
)
|
||||
if brief is None:
|
||||
return {}
|
||||
return {"mail_brief_payload": brief.to_banner_dict()}
|
||||
|
||||
|
||||
def navbar_context(request):
|
||||
if not request.user.is_authenticated:
|
||||
return {}
|
||||
|
||||
@@ -105,6 +105,7 @@ TEMPLATES = [
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'core.context_processors.user_palette',
|
||||
'core.context_processors.navbar_context',
|
||||
'core.context_processors.mail_brief_payload',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
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)
|
||||
@@ -86,6 +86,7 @@
|
||||
#id_billboard_applet_menu { @extend %applet-menu; }
|
||||
#id_billscroll_menu { @extend %applet-menu; }
|
||||
#id_my_sea_menu { @extend %applet-menu; }
|
||||
#id_bud_menu { @extend %applet-menu; }
|
||||
|
||||
// Page-level gear buttons — fixed to viewport bottom-right
|
||||
.gameboard-page,
|
||||
@@ -95,7 +96,8 @@
|
||||
.post-page,
|
||||
.billboard-page,
|
||||
.billscroll-page,
|
||||
.my-sea-page {
|
||||
.my-sea-page,
|
||||
.bud-page {
|
||||
> .gear-btn {
|
||||
position: fixed;
|
||||
bottom: 4.2rem;
|
||||
@@ -112,7 +114,8 @@
|
||||
#id_post_menu,
|
||||
#id_billboard_applet_menu,
|
||||
#id_billscroll_menu,
|
||||
#id_my_sea_menu {
|
||||
#id_my_sea_menu,
|
||||
#id_bud_menu {
|
||||
position: fixed;
|
||||
bottom: 6.6rem;
|
||||
right: 1rem;
|
||||
@@ -137,7 +140,8 @@
|
||||
.post-page > .gear-btn,
|
||||
.billboard-page > .gear-btn,
|
||||
.billscroll-page > .gear-btn,
|
||||
.my-sea-page > .gear-btn {
|
||||
.my-sea-page > .gear-btn,
|
||||
.bud-page > .gear-btn {
|
||||
top: 0.5rem;
|
||||
bottom: auto;
|
||||
right: 4.2rem;
|
||||
@@ -151,7 +155,8 @@
|
||||
#id_post_menu,
|
||||
#id_billboard_applet_menu,
|
||||
#id_billscroll_menu,
|
||||
#id_my_sea_menu {
|
||||
#id_my_sea_menu,
|
||||
#id_bud_menu {
|
||||
position: fixed;
|
||||
top: 2.6rem;
|
||||
right: 4.2rem;
|
||||
|
||||
@@ -123,9 +123,15 @@
|
||||
}
|
||||
|
||||
// Active sub-btn = fully visible; inactive (default) = 0.6 opacity hint.
|
||||
// Active conditions are wired one-by-one in later sprints.
|
||||
// `z-index: 1` raises the active sub-btn above its inactive siblings so
|
||||
// click hit-testing picks the active one even while the fan is mid-
|
||||
// transition (during the 0.25s arc-out animation all 5 sub-btns share
|
||||
// the fan-centre origin, and a later-in-DOM inactive btn would otherwise
|
||||
// obscure the active target). Active conditions are wired one-by-one in
|
||||
// later sprints.
|
||||
#id_burger_btn.active ~ #id_burger_fan .burger-fan-btn.active {
|
||||
opacity: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
#id_burger_btn.active ~ #id_burger_fan .burger-fan-btn:not(.active) {
|
||||
opacity: 0.6;
|
||||
|
||||
11
src/templates/apps/billboard/_partials/_bud_gear.html
Normal file
11
src/templates/apps/billboard/_partials/_bud_gear.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{# Gear menu on bud.html — mirrors apps/billboard/_partials/_post_gear.html. #}
|
||||
{# NVM returns to /billboard/my-buds/; DEL opens the shared #id_guard_portal #}
|
||||
{# from base.html via the `data-confirm` hook + POSTs to delete_bud. #}
|
||||
<div id="id_bud_menu" style="display:none">
|
||||
<a href="{% url 'billboard:my_buds' %}" class="btn btn-cancel">NVM</a>
|
||||
<form method="POST" action="{% url 'billboard:delete_bud' bud.id %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-danger" data-confirm="Delete this bud?">DEL</button>
|
||||
</form>
|
||||
</div>
|
||||
{% include "apps/applets/_partials/_gear.html" with menu_id="id_bud_menu" %}
|
||||
13
src/templates/apps/billboard/_partials/_bud_tooltip.html
Normal file
13
src/templates/apps/billboard/_partials/_bud_tooltip.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{# Shared tooltip-portal markup for the My Buds list. Hidden by default; #}
|
||||
{# JS reads data-tt-* attrs from the clicked .bud-entry row, populates #}
|
||||
{# the slots below, and toggles .active. .tt-milestone is removed from #}
|
||||
{# the DOM when no data-tt-milestone attr is present so the FT can #}
|
||||
{# distinguish never-edited from cleared-after-edit (slot absent vs. #}
|
||||
{# present-but-empty). #}
|
||||
<div id="id_tooltip_portal" class="tt">
|
||||
<span class="tt-title"></span>
|
||||
<span class="tt-description"></span>
|
||||
<span class="tt-email"></span>
|
||||
<span class="tt-shoptalk"></span>
|
||||
<span class="tt-milestone"></span>
|
||||
</div>
|
||||
@@ -1,4 +1,15 @@
|
||||
{% load lyric_extras %}
|
||||
<li class="applet-list-entry bud-entry" data-bud-id="{{ item.id }}">
|
||||
<span class="bud-name">{{ item|at_handle }}</span>
|
||||
{# My Buds page row — `@<handle> the <Title>` w. anchor on the handle #}
|
||||
{# routing to the bud's landing page + data-tt-* attrs the tooltip #}
|
||||
{# portal reads on row-lock click. `item.shoptalk_text` + `.milestone_ #}
|
||||
{# dt` are attached by the my_buds view from BudshipNote. #}
|
||||
<li class="applet-list-entry bud-entry"
|
||||
data-bud-id="{{ item.id }}"
|
||||
data-tt-title="{{ item|at_handle }}"
|
||||
data-tt-description="{{ item.active_title_display }}"
|
||||
data-tt-email="{{ item.email }}"
|
||||
data-tt-shoptalk="{{ item.shoptalk_text|default:'' }}"
|
||||
{% if item.milestone_dt %}data-tt-milestone="edited {{ item.milestone_dt|relative_ts }}"{% endif %}>
|
||||
<span class="bud-name"><a href="{% url 'billboard:bud_page' item.id %}">{{ item|at_handle }}</a></span>
|
||||
<span class="bud-row-title"> the {{ item.active_title_display }}</span>
|
||||
</li>
|
||||
|
||||
85
src/templates/apps/billboard/bud.html
Normal file
85
src/templates/apps/billboard/bud.html
Normal file
@@ -0,0 +1,85 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% load static %}
|
||||
{% load lyric_extras %}
|
||||
|
||||
{% block title_text %}Billbud{% endblock title_text %}
|
||||
{% block header_text %}<span>Bill</span><span>bud</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
{# note.js exposes window.Brief — needed by the auto-fired @mailman Brief #}
|
||||
{# when this page is the destination of an invite cascade. #}
|
||||
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
||||
|
||||
<div class="bud-page">
|
||||
<header class="bud-page-header">
|
||||
<h3>{{ bud|at_handle }} the {{ bud.active_title_display }}</h3>
|
||||
<p class="bud-page-email">{{ bud.email }}</p>
|
||||
</header>
|
||||
|
||||
<form id="id_bud_shoptalk_form"
|
||||
method="POST"
|
||||
action="{% url 'billboard:save_bud_shoptalk' bud.id %}"
|
||||
class="bud-shoptalk-form">
|
||||
{% csrf_token %}
|
||||
<label for="id_shoptalk" class="bud-shoptalk-label">Shoptalk</label>
|
||||
<textarea id="id_shoptalk"
|
||||
name="shoptalk"
|
||||
maxlength="160"
|
||||
class="form-control bud-shoptalk-input"
|
||||
placeholder="Your personal note about this bud…">{{ shoptalk_text }}</textarea>
|
||||
</form>
|
||||
|
||||
{# 4-btn apparatus mirrors my_sea.html: kit (from base.html) + bud + #}
|
||||
{# gear + burger. Apparatus loads bud-btn.js (defines window. #}
|
||||
{# bindBudBtn — does NOT auto-bind); the inline script below opens #}
|
||||
{# the panel + stubs OK to disabled per the sprint plan. #}
|
||||
{% include "apps/billboard/_partials/_bud_apparatus.html" with aria_label="Bud" include_suggestions=False %}
|
||||
<script>
|
||||
(function () {
|
||||
var btn = document.getElementById('id_bud_btn');
|
||||
var panel = document.getElementById('id_bud_panel');
|
||||
var ok = document.getElementById('id_bud_ok');
|
||||
if (!btn || !panel || !ok) return;
|
||||
// bud-of-bud flow isn't wired yet — stub OK as disabled w. × text.
|
||||
ok.classList.add('btn-disabled');
|
||||
ok.textContent = '×';
|
||||
ok.setAttribute('disabled', 'disabled');
|
||||
btn.addEventListener('click', function () {
|
||||
var open = document.documentElement.classList.toggle('bud-open');
|
||||
btn.classList.toggle('active', open);
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && document.documentElement.classList.contains('bud-open')) {
|
||||
document.documentElement.classList.remove('bud-open');
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
|
||||
{% include "apps/billboard/_partials/_bud_gear.html" %}
|
||||
|
||||
{# Burger fan — sea_btn_active + sea_first_draw_pending drive the #}
|
||||
{# server-side .active / .glow-handoff classes (same vars my_sea + #}
|
||||
{# room read). burger-btn.js auto-binds on DOMContentLoaded. #}
|
||||
{% include "apps/gameboard/_partials/_burger.html" %}
|
||||
</div>
|
||||
|
||||
<script src="{% static 'apps/epic/burger-btn.js' %}"></script>
|
||||
{% if pending_invite %}
|
||||
<script>
|
||||
(function () {
|
||||
var sea = document.getElementById('id_sea_btn');
|
||||
if (!sea) return;
|
||||
// Active sub-btn navigation handler — fires BEFORE burger-btn.js's
|
||||
// delegated fan handler (target-phase before bubble-up) so the click
|
||||
// routes to the bud's spectator my-sea page. Burger then closes the
|
||||
// fan as usual.
|
||||
sea.addEventListener('click', function () {
|
||||
if (!sea.classList.contains('active')) return;
|
||||
window.location.href = '/gameboard/my-sea/visit/{{ bud.id }}/';
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
@@ -11,6 +11,10 @@
|
||||
{% include "apps/applets/_partials/_applet-list-shell.html" with shell_title="My Buds" shell_list_id="id_buds_list" shell_items=buds shell_item_template="apps/billboard/_partials/_my_buds_item.html" shell_empty="No buds yet." %}
|
||||
</div>
|
||||
|
||||
{# Tooltip portal — populated from .bud-entry data-tt-* attrs on row #}
|
||||
{# click. See my-buds-tooltip.js for the bind logic. #}
|
||||
{% include "apps/billboard/_partials/_bud_tooltip.html" %}
|
||||
|
||||
{# Bud btn (bottom-left) + slide-out add-bud panel — async POST to add_bud. #}
|
||||
{% include "apps/billboard/_partials/_bud_add_panel.html" %}
|
||||
{% endblock content %}
|
||||
@@ -19,4 +23,5 @@
|
||||
{# Brief module — needed by _bud_add_panel's OK handler so the #}
|
||||
{# duplicate-add error banner can render via Brief.showDuplicateBanner.#}
|
||||
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
||||
<script src="{% static 'apps/billboard/my-buds-tooltip.js' %}"></script>
|
||||
{% endblock scripts %}
|
||||
|
||||
@@ -41,12 +41,14 @@
|
||||
{% for line in post.lines.all %}
|
||||
<li class="post-line {% if line.author.username == 'adman' or line.author.username == 'taxman' or line.author.username == 'mailman' %}post-line--system{% endif %}">
|
||||
<span class="post-line-author">{{ line.author|at_handle }}</span>
|
||||
<span class="post-line-text">{# adman / taxman-authored Lines (note unlock, share invite, tax ledger system prose) may carry an `<a class="note-ref">` anchor that needs to render as HTML. User-typed Lines stay escaped. `display_text` strips the `[<iso timestamp>] ` prefix that tax-ledger Lines carry to satisfy `unique_together = (post, text)` — the per-line `created_at` timestamp on the right renders the user-facing moment. #}{% if line.author.username == 'adman' or line.author.username == 'taxman' or line.author.username == 'mailman' %}{{ line.display_text|safe }}{% else %}{{ line.display_text }}{% endif %}</span>
|
||||
<span class="post-line-text">{# adman / taxman / mailman-authored Lines (note unlock, share invite, tax ledger, invite cascade) may carry HTML anchors (note-ref / post-attribution). User-typed Lines stay escaped. `display_text` strips the `[<iso timestamp>] ` prefix that tax-ledger Lines carry to satisfy `unique_together = (post, text)` — the per-line `created_at` timestamp on the right renders the user-facing moment. #}{% if line.author.username == 'adman' or line.author.username == 'taxman' or line.author.username == 'mailman' %}{{ line.display_text|safe }}{% else %}{{ line.display_text }}{% endif %}</span>
|
||||
<time class="post-line-time" datetime="{{ line.created_at|date:'c' }}">{{ line.created_at|relative_ts }}</time>
|
||||
{# @mailman invite Lines carry an OK/BYE action block driven by #}
|
||||
{# the linked SeaInvite's status (my-sea invite flow). Non-invite #}
|
||||
{# system + user Lines have no `sea_invite`, so this is skipped. #}
|
||||
{% if line.sea_invite %}{% include "apps/billboard/_partials/_invite_actions.html" %}{% endif %}
|
||||
{# Pre-sprint @mailman invite Lines carried an in-line OK/BYE #}
|
||||
{# block via _invite_actions.html. Bud landing page sprint #}
|
||||
{# 2026-05-27 migrates that interaction onto bud.html — the #}
|
||||
{# Line's prose now embeds a post-attribution anchor (see #}
|
||||
{# apps.billboard.mail.INVITE_TEMPLATE) that routes to the #}
|
||||
{# owner's bud page where accept/decline/spectator live. #}
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="post-line-buffer" aria-hidden="true"></li>
|
||||
|
||||
@@ -73,6 +73,24 @@
|
||||
|
||||
{% block scripts %}
|
||||
{% endblock scripts %}
|
||||
{# Cross-page @mailman Brief surface — bud landing page sprint #}
|
||||
{# 2026-05-27. Server-side context processor injects mail_ #}
|
||||
{# brief_payload on every authenticated response when the user #}
|
||||
{# has an unread MAIL_ACCEPTANCE Brief; banner auto-fires here #}
|
||||
{# (note.js is included by pages that need Brief earlier; this #}
|
||||
{# loads it if no page-block scripts pulled it in already). #}
|
||||
{% if mail_brief_payload %}
|
||||
{{ mail_brief_payload|json_script:"id_mail_brief_payload" }}
|
||||
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var el = document.getElementById('id_mail_brief_payload');
|
||||
if (!el || !window.Brief || !Brief.showBanner) return;
|
||||
var payload = JSON.parse(el.textContent || '{}');
|
||||
if (payload && payload.title) Brief.showBanner(payload);
|
||||
}());
|
||||
</script>
|
||||
{% endif %}
|
||||
<script>
|
||||
// h2 letter splitter — wrap each character of every .row .col-lg-6 h2
|
||||
// word-span in its own <span> so .scss can use justify-content:
|
||||
|
||||
Reference in New Issue
Block a user