Files
python-tdd/src/apps/billboard/tests/integrated/test_views.py
Disco DeDisco f5ee83be0a bud page sea-btn cascade: live-invite window + accept-on-GET + glow handoff; my-buds tooltip clamp + row hover/lock — TDD
Fixes the Bill Bud invite cascade so the sea sub-btn actually lights + leads
to the bud's my_sea, and gives the My Buds row tooltip viewport clamping +
hover/lock styling.

SeaInvite.invitee_access_open (gameboard/models.py): new invitee-facing
access window — a non-terminal invite (PENDING/ACCEPTED) within 24h of being
proffered OR within 24h of the invitee's last gate token deposit. Re-arms on
each deposit; DECLINED/LEFT/EXPIRED stay shut. Distinct from is_expired
(which only models the PENDING lapse). 8 UTs.

bud_page (billboard/views.py): sea_btn_active / sea_first_draw_pending now key
on invitee_access_open across PENDING + ACCEPTED, not PENDING-only. Old design
darkened the btn the instant the user accepted, so they could never reach the
bud's sea from here post-accept — that was the red .fa-ban the user saw. ITs
updated: accepted-within-window now lights; added stale-accepted-dark +
recent-deposit-relights cases.

my_sea_visit (gameboard/views.py): accept-on-GET — a still-pending, non-expired
invite from the owner to the visitor is accepted implicitly on arrival (the
sea-btn cascade + @mailman post-attribution anchor both land here, so the click
IS the acceptance). Previously PENDING → 403, so the cascade dead-ended. ITs:
pending-invitee now auto-accepts (200); expired-pending still 403s; stranger
still 403s.

bud.html: burger → sea_btn glow-handoff machine (the my_sea.html cascade minus
the spread-modal stage) so the glow rides the affordance chain to the click
target; active sea click clears glow, preserves .active, navigates.

my-buds-tooltip.js: clamp the position:fixed #id_tooltip_portal to the viewport
on row-lock — same 1rem-inset shape as game-kit.js / sky-wheel.js / wallet.js
(measure after .active, clamp left, prefer above / flip below). Reset on clear.

_billboard.scss: .bud-entry hover + .row-locked highlight (rows aren't
.row-3col so the existing rule missed them) — fill --secUser, flip the
--terUser handle to --quiUser, trailing title to readable --priUser.

520 billboard+gameboard ITs/UTs green; affected sea-btn-cascade FT green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:36:25 -04:00

1532 lines
66 KiB
Python

import json as _json
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.applets.models import Applet
from apps.drama.models import GameEvent, Note, ScrollPosition, record
from apps.epic.models import Room
from apps.lyric.models import User
def _seed_billboard_applets():
for slug, name, cols, rows in [
("my-scrolls", "My Scrolls", 4, 3),
("my-buds", "My Buds", 4, 3),
("most-recent-scroll", "Most Recent Scroll", 8, 6),
("my-sign", "My Sign", 4, 6),
]:
Applet.objects.get_or_create(
slug=slug,
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
)
class BillboardViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@billboard.io")
self.client.force_login(self.user)
_seed_billboard_applets()
def test_uses_billboard_template(self):
response = self.client.get("/billboard/")
self.assertTemplateUsed(response, "apps/billboard/billboard.html")
def test_passes_applets_context(self):
response = self.client.get("/billboard/")
self.assertIn("applets", response.context)
slugs = [e["applet"].slug for e in response.context["applets"]]
self.assertIn("my-scrolls", slugs)
self.assertIn("my-buds", slugs)
self.assertIn("most-recent-scroll", slugs)
def test_passes_my_rooms_context(self):
room = Room.objects.create(name="Test Room", owner=self.user)
response = self.client.get("/billboard/")
self.assertIn(room, response.context["my_rooms"])
def test_passes_recent_room_and_events(self):
room = Room.objects.create(name="Test Room", owner=self.user)
record(
room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
response = self.client.get("/billboard/")
self.assertEqual(response.context["recent_room"], room)
self.assertEqual(len(response.context["recent_events"]), 1)
def test_recent_events_capped_at_36(self):
room = Room.objects.create(name="Test Room", owner=self.user)
for i in range(40):
record(
room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
response = self.client.get("/billboard/")
self.assertEqual(len(response.context["recent_events"]), 36)
def test_recent_events_in_chronological_order(self):
room = Room.objects.create(name="Test Room", owner=self.user)
for _ in range(3):
record(
room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
response = self.client.get("/billboard/")
events = response.context["recent_events"]
timestamps = [e.timestamp for e in events]
self.assertEqual(timestamps, sorted(timestamps))
def test_recent_room_is_none_when_no_events(self):
response = self.client.get("/billboard/")
self.assertIsNone(response.context["recent_room"])
self.assertEqual(list(response.context["recent_events"]), [])
# ── recent_buds + recent_notes (applet feed) ──────────────────────
# Mirrors the recent_posts pattern: each in-grid applet now lists
# its own most-recent items (capped at 3) plus an empty-state row.
def test_passes_recent_buds_context(self):
first = User.objects.create(email="first-bud@test.io")
second = User.objects.create(email="second-bud@test.io")
self.user.buds.add(first)
self.user.buds.add(second)
response = self.client.get("/billboard/")
# Most-recently-added bud is first
self.assertEqual(list(response.context["recent_buds"]), [second, first])
def test_recent_buds_capped_at_3(self):
added = [
User.objects.create(email=f"bud{i}@test.io") for i in range(5)
]
for u in added:
self.user.buds.add(u)
response = self.client.get("/billboard/")
recent = list(response.context["recent_buds"])
self.assertEqual(len(recent), 3)
# Newest three in newest-first order
self.assertEqual(recent, list(reversed(added))[:3])
def test_recent_buds_empty_when_no_buds(self):
response = self.client.get("/billboard/")
self.assertEqual(list(response.context["recent_buds"]), [])
def test_passes_recent_notes_context(self):
Note.objects.create(
user=self.user, slug="stargazer",
earned_at=timezone.now() - timezone.timedelta(days=2),
)
Note.objects.create(
user=self.user, slug="super-schizo",
earned_at=timezone.now(),
)
response = self.client.get("/billboard/")
slugs = [n.slug for n in response.context["recent_notes"]]
# Most-recently-earned first
self.assertEqual(slugs, ["super-schizo", "stargazer"])
def test_recent_notes_capped_at_3(self):
slugs = ["stargazer", "super-schizo", "super-nomad", "ladidah", "doodah"]
base = timezone.now()
for i, slug in enumerate(slugs):
Note.objects.create(
user=self.user, slug=slug,
earned_at=base - timezone.timedelta(hours=i),
)
response = self.client.get("/billboard/")
names = [n.slug for n in response.context["recent_notes"]]
self.assertEqual(len(names), 3)
# Newest-first; oldest two trimmed
self.assertEqual(names, slugs[:3])
def test_recent_notes_excludes_other_users(self):
other = User.objects.create(email="other-note@test.io")
Note.objects.create(user=other, slug="stargazer", earned_at=timezone.now())
response = self.client.get("/billboard/")
self.assertEqual(list(response.context["recent_notes"]), [])
def test_recent_notes_empty_when_no_notes(self):
response = self.client.get("/billboard/")
self.assertEqual(list(response.context["recent_notes"]), [])
# ── 3-column applet row contract ──────────────────────────────────
# Each applet's list item renders `<title> | <body> | <ts>` —
# title + body wrapped in `.row-title` / `.row-body`, timestamp
# in a `<time class="row-ts">` element. The body shows the
# most-recent activity (post line / room event / note description /
# bud's active title). Right column carries `relative_ts` text.
def test_my_posts_applet_row_shows_latest_line_and_ts(self):
from apps.billboard.models import Line, Post
post = Post.objects.create(
owner=self.user, kind=Post.KIND_USER_POST, title="StampedPost",
)
Line.objects.create(post=post, text="first line text", author=self.user)
Line.objects.create(post=post, text="latest line text", author=self.user)
response = self.client.get("/billboard/")
body = response.content.decode()
# Title preserved
self.assertIn("StampedPost", body)
# Latest line surfaces in the row body, not the older line
self.assertIn("latest line text", body)
# A <time> ts element renders on the row
self.assertRegex(body, r'<time[^>]+class="[^"]*row-ts')
def test_my_buds_applet_row_shows_active_title_and_since_ts(self):
bud = User.objects.create(email="alice@test.io", username="alice")
note = Note.objects.create(
user=bud, slug="stargazer", earned_at=timezone.now(),
)
bud.active_title = note
bud.save()
self.user.buds.add(bud)
response = self.client.get("/billboard/")
body = response.content.decode()
# Bud handle shows; the active title surfaces as the row body;
# the ts column carries a "since " prefix unique to buds
self.assertIn("alice", body)
self.assertIn("the Stargazer", body)
self.assertRegex(body, r'class="[^"]*row-ts[^"]*"[^>]*>\s*since\s+')
def test_my_buds_applet_row_no_active_title_no_body_or_ts(self):
bud = User.objects.create(email="alice@test.io", username="alice")
# No active_title donned
self.user.buds.add(bud)
response = self.client.get("/billboard/")
body = response.content.decode()
self.assertIn("alice", body)
# Without an active title we don't render a "since" line
self.assertNotRegex(body, r'class="[^"]*row-ts[^"]*"[^>]*>\s*since\s+')
def test_my_notes_applet_row_shows_description_and_earned_at(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
response = self.client.get("/billboard/")
body = response.content.decode()
self.assertIn("Stargazer", body)
# Description prefix from _NOTE_META — full string is 40 chars and
# truncates to 32+"..." via the `truncate_title` filter, so assert
# against the head only ("You saved your first personal" is 30 chars,
# comfortably inside the truncation window).
self.assertIn("You saved your first personal", body)
# ts column present
self.assertRegex(body, r'<time[^>]+class="[^"]*row-ts')
def test_my_scrolls_applet_row_shows_latest_event_and_ts(self):
room = Room.objects.create(name="StampedRoom", owner=self.user)
record(
room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
response = self.client.get("/billboard/")
body = response.content.decode()
self.assertIn("StampedRoom", body)
# The slot-fill prose mentions the token; we just check the room's
# scroll row carries a <time> + some body content (event prose)
self.assertRegex(body, r'<time[^>]+class="[^"]*row-ts')
def test_my_scrolls_applet_row_body_includes_actor_display_name(self):
"""Latest event prose w.o. the actor name is meaningless — `deposits
a Carte Blanche` should read `<actor> deposits a Carte Blanche`.
scroll.html renders actor via a separate `<strong>` adjacent to
prose; the applet row has a single body cell so we concatenate.
Scoped to the `.row-body` span (vs. a loose substring match) so
we don't pass on the Most Recent Scroll applet's actor render
— which renders the same actor too, separately."""
actor = User.objects.create(email="stuart@test.io", username="stuart")
room = Room.objects.create(name="ScrollRoom", owner=self.user)
record(
room, GameEvent.SLOT_FILLED, actor=actor,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
response = self.client.get("/billboard/")
body = response.content.decode()
# The `.row-body` cell inside the My Scrolls applet must carry
# both the actor handle AND the event prose, in that order.
self.assertRegex(
body,
r'<span class="row-body">[^<]*stuart[^<]*deposits a Coin-on-a-String',
)
def test_my_scrolls_applet_row_body_no_actor_prefix_for_welcome(self):
"""Welcome events (actor=None) must not render an empty `<strong></strong>`
prefix before the prose — same shape the scroll.html template adopted."""
room = Room.objects.create(name="GreenroomTwo", owner=self.user)
record(room, GameEvent.ROOM_CREATED)
response = self.client.get("/billboard/")
body = response.content.decode()
self.assertIn("Welcome to GreenroomTwo!", body)
# No empty <strong> before the welcome line
self.assertNotRegex(
body, r'<strong>\s*</strong>\s*Welcome to GreenroomTwo!'
)
class SaveScrollPositionViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="reader@test.io")
self.client.force_login(self.user)
self.room = Room.objects.create(name="Test Room", owner=self.user)
self.url = f"/billboard/room/{self.room.id}/scroll-position/"
def test_get_returns_405(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 405)
class ToggleBillboardAppletsTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@toggle.io")
self.client.force_login(self.user)
_seed_billboard_applets()
def test_toggle_hides_unchecked_applets(self):
response = self.client.post(
reverse("billboard:toggle_applets"),
{"applets": ["my-scrolls"]},
)
self.assertEqual(response.status_code, 302)
from apps.applets.models import UserApplet
contacts = Applet.objects.get(slug="my-buds")
ua = UserApplet.objects.get(user=self.user, applet=contacts)
self.assertFalse(ua.visible)
def test_toggle_returns_partial_on_htmx(self):
response = self.client.post(
reverse("billboard:toggle_applets"),
{"applets": ["my-scrolls"]},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/billboard/_partials/_applets.html")
def test_htmx_toggle_response_renders_most_recent_scroll_with_real_events(self):
# Seed a room + event so Most Recent Scroll renders prose, not the empty fallback.
room = Room.objects.create(name="Sound Chamber", owner=self.user)
record(
room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
response = self.client.post(
reverse("billboard:toggle_applets"),
{"applets": [
"my-scrolls",
"my-buds",
"most-recent-scroll",
]},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Coin-on-a-String")
# And My Scrolls renders the room name (needs my_rooms in context).
self.assertContains(response, "Sound Chamber")
def test_htmx_toggle_response_has_single_applet_menu_div(self):
# The response is hx-swapped into the page; if it contains both the menu
# div and the applets-container div, the original menu remains and the
# next gear-click resurrects stale form state. Response must contain the
# menu exactly once (the wrapper) — never two siblings of the same id.
response = self.client.post(
reverse("billboard:toggle_applets"),
{"applets": ["my-scrolls"]},
HTTP_HX_REQUEST="true",
)
body = response.content.decode("utf-8")
self.assertEqual(body.count('id="id_billboard_applet_menu"'), 1)
def test_second_toggle_preserves_prior_hidden_state(self):
# First toggle: hide My Buds only.
self.client.post(
reverse("billboard:toggle_applets"),
{"applets": [
"new-post", "my-posts",
"my-scrolls",
"most-recent-scroll",
]},
HTTP_HX_REQUEST="true",
)
# Second toggle: hide Most Recent Scroll additionally — My Buds must stay hidden.
self.client.post(
reverse("billboard:toggle_applets"),
{"applets": [
"new-post", "my-posts",
"my-scrolls",
]},
HTTP_HX_REQUEST="true",
)
from apps.applets.models import UserApplet
contacts = Applet.objects.get(slug="my-buds")
most_recent_scroll = Applet.objects.get(slug="most-recent-scroll")
self.assertFalse(
UserApplet.objects.get(user=self.user, applet=contacts).visible
)
self.assertFalse(
UserApplet.objects.get(user=self.user, applet=most_recent_scroll).visible
)
class BillscrollViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@billscroll.io")
self.client.force_login(self.user)
self.room = Room.objects.create(name="Test Room", owner=self.user)
record(
self.room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
def test_uses_scroll_template(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertTemplateUsed(response, "apps/billboard/scroll.html")
def test_passes_events_context(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertIn("events", response.context)
self.assertEqual(response.context["events"].count(), 1)
def test_passes_page_class_billscroll(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertEqual(response.context["page_class"], "page-billscroll")
def test_passes_scroll_position_zero_when_none_saved(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertEqual(response.context["scroll_position"], 0)
def test_passes_saved_scroll_position_in_context(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=250)
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertEqual(response.context["scroll_position"], 250)
def test_scroll_renders_event_body_and_time_columns(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertContains(response, 'class="drama-event-body"')
self.assertContains(response, 'class="drama-event-time"')
class NotePageViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="recog@test.io")
self.client.force_login(self.user)
def test_requires_login(self):
self.client.logout()
response = self.client.get("/billboard/my-notes/")
self.assertEqual(response.status_code, 302)
def test_returns_200(self):
response = self.client.get("/billboard/my-notes/")
self.assertEqual(response.status_code, 200)
def test_uses_note_page_template(self):
response = self.client.get("/billboard/my-notes/")
self.assertTemplateUsed(response, "apps/billboard/my_notes.html")
def test_passes_notes_in_context(self):
recog = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/my-notes/")
self.assertIn(recog, response.context["notes"])
def test_excludes_other_users_notes(self):
other = User.objects.create(email="other@test.io")
Note.objects.create(
user=other, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/my-notes/")
self.assertEqual(list(response.context["notes"]), [])
def test_renders_recog_list_and_items(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/my-notes/")
self.assertContains(response, 'class="note-list"')
self.assertContains(response, 'class="note-item"')
def test_renders_recog_item_title_description_image_box(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/my-notes/")
self.assertContains(response, 'class="note-item__title"')
self.assertContains(response, 'class="note-item__description"')
self.assertContains(response, 'class="note-item__image-box"')
def test_palette_modal_renders_swatch_labels(self):
"""Each palette option in the swatch modal should display its human-readable
label next to the swatch body so the user knows what they are choosing."""
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/my-notes/")
self.assertContains(response, 'class="note-swatch-label"')
self.assertContains(response, "Bardo")
self.assertContains(response, "Sheol")
class NoteSetPaletteViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="setpal@test.io")
self.client.force_login(self.user)
self.note = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
self.url = "/billboard/note/stargazer/set-palette"
def test_requires_login(self):
self.client.logout()
response = self.client.post(
self.url,
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 302)
def test_sets_palette_on_note(self):
self.client.post(
self.url,
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.note.refresh_from_db()
self.assertEqual(self.note.palette, "palette-bardo")
def test_returns_200_with_ok(self):
response = self.client.post(
self.url,
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"ok": True})
def test_returns_404_for_slug_user_does_not_own(self):
response = self.client.post(
"/billboard/note/schizo/set-palette",
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 404)
def test_also_saves_user_palette(self):
"""note_set_palette must persist the choice to user.palette so the
palette survives page navigation (sitewide commitment)."""
self.client.post(
self.url,
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.user.refresh_from_db()
self.assertEqual(self.user.palette, "palette-bardo")
class NoteEquipTitleViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="don@test.io")
self.client.force_login(self.user)
self.note = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
def test_don_sets_active_title(self):
self.client.post("/billboard/note/stargazer/don")
self.user.refresh_from_db()
self.assertEqual(self.user.active_title, self.note)
def test_doff_clears_active_title(self):
self.user.active_title = self.note
self.user.save(update_fields=["active_title"])
self.client.post("/billboard/note/stargazer/doff")
self.user.refresh_from_db()
self.assertIsNone(self.user.active_title)
def test_don_returns_200_with_title(self):
response = self.client.post("/billboard/note/stargazer/don")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["title"], "Stargazer")
def test_doff_returns_200(self):
response = self.client.post("/billboard/note/stargazer/doff")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["ok"])
self.assertEqual(data["greeting"], "Welcome,")
self.assertEqual(data["title"], "Earthman")
def test_don_requires_login(self):
self.client.logout()
response = self.client.post("/billboard/note/stargazer/don")
self.assertEqual(response.status_code, 302)
def test_don_returns_404_for_unowned_note(self):
other = User.objects.create(email="other@test.io")
Note.objects.create(user=other, slug="stargazer", earned_at=timezone.now())
self.client.logout()
self.client.force_login(other)
response = self.client.post("/billboard/note/stargazer/don")
# other user's own note — should work
self.assertEqual(response.status_code, 200)
class SaveScrollPositionTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@savescroll.io")
self.client.force_login(self.user)
self.room = Room.objects.create(name="Test Room", owner=self.user)
def test_post_saves_scroll_position(self):
self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 300},
)
sp = ScrollPosition.objects.get(user=self.user, room=self.room)
self.assertEqual(sp.position, 300)
def test_post_updates_existing_position(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=100)
self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 450},
)
self.assertEqual(
ScrollPosition.objects.get(user=self.user, room=self.room).position, 450
)
def test_post_returns_204(self):
response = self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 100},
)
self.assertEqual(response.status_code, 204)
def test_post_requires_login(self):
self.client.logout()
response = self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 100},
)
self.assertEqual(response.status_code, 302)
class PostLineRelativeTimestampTest(TestCase):
"""post.html mirrors scroll.html's bucketed `relative_ts` time rendering:
same-day Lines show a time; older ones collapse to weekday / month-day /
month-day-year. Bypasses `auto_now_add` with a queryset .update() so the
test can backdate Lines."""
def setUp(self):
self.owner = User.objects.create(email="owner@post-ts.io", username="owner")
self.client.force_login(self.owner)
from apps.billboard.models import Line, Post
self.Line = Line
self.post = Post.objects.create(owner=self.owner, title="Stamp")
def _backdate(self, line, **delta):
from apps.billboard.models import Line
Line.objects.filter(pk=line.pk).update(
created_at=timezone.now() - timezone.timedelta(**delta)
)
def test_recent_line_renders_clock_time(self):
self.Line.objects.create(post=self.post, text="now", author=self.owner)
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
self.assertRegex(
response.content.decode(),
r'class="post-line-time"[^>]*>\s*\d+:\d{2}\s*[ap]\.m\.\s*<',
)
def test_two_day_old_line_renders_weekday(self):
line = self.Line.objects.create(post=self.post, text="old", author=self.owner)
self._backdate(line, days=2)
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
import re
m = re.search(
r'class="post-line-time"[^>]*>\s*(\w+)\s*<', response.content.decode()
)
self.assertIsNotNone(m, "no .post-line-time cell rendered")
self.assertIn(m.group(1), {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"})
def test_thirty_day_old_line_renders_day_month(self):
line = self.Line.objects.create(post=self.post, text="oldr", author=self.owner)
self._backdate(line, days=30)
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
self.assertRegex(
response.content.decode(),
r'class="post-line-time"[^>]*>\s*\d{2}\s\w{3}\s*<',
)
class DeletePostViewTest(TestCase):
"""billboard:delete_post — owner can hard-delete; non-owners are no-op;
note_unlock Posts are protected (defence-in-depth alongside the menu
branch that doesn't render DEL on admin-Posts)."""
def setUp(self):
from apps.billboard.models import Line, Post
self.Post = Post
self.owner = User.objects.create(email="del-owner@test.io")
self.other = User.objects.create(email="del-other@test.io")
self.post = Post.objects.create(
owner=self.owner, kind=Post.KIND_USER_POST, title="X",
)
Line.objects.create(post=self.post, text="x", author=self.owner)
def test_owner_post_redirects_to_my_posts(self):
self.client.force_login(self.owner)
response = self.client.post(
reverse("billboard:delete_post", args=[self.post.id])
)
self.assertRedirects(
response,
reverse("billboard:my_posts", args=[self.owner.id]),
fetch_redirect_response=False,
)
def test_owner_post_deletes_post(self):
self.client.force_login(self.owner)
self.client.post(reverse("billboard:delete_post", args=[self.post.id]))
self.assertFalse(self.Post.objects.filter(id=self.post.id).exists())
def test_non_owner_cannot_delete(self):
self.client.force_login(self.other)
self.client.post(reverse("billboard:delete_post", args=[self.post.id]))
self.assertTrue(self.Post.objects.filter(id=self.post.id).exists())
def test_get_does_not_delete(self):
self.client.force_login(self.owner)
self.client.get(reverse("billboard:delete_post", args=[self.post.id]))
self.assertTrue(self.Post.objects.filter(id=self.post.id).exists())
def test_note_unlock_post_is_protected(self):
# Even the owner can't DEL a system thread — the gear menu doesn't
# render DEL for note_unlock, but the view is hardened in case the
# POST is forged.
admin_post = self.Post.objects.create(
owner=self.owner, kind=self.Post.KIND_NOTE_UNLOCK, title="Notes",
)
self.client.force_login(self.owner)
self.client.post(reverse("billboard:delete_post", args=[admin_post.id]))
self.assertTrue(self.Post.objects.filter(id=admin_post.id).exists())
class AbandonPostViewTest(TestCase):
"""billboard:abandon_post — invitee removes themselves from
post.shared_with; owner unaffected; other invitees unaffected; admin
Posts protected from BYE."""
def setUp(self):
from apps.billboard.models import Line, Post
self.Post = Post
self.owner = User.objects.create(email="abandon-owner@test.io")
self.invitee = User.objects.create(email="abandon-invitee@test.io")
self.other = User.objects.create(email="abandon-other@test.io")
self.post = Post.objects.create(
owner=self.owner, kind=Post.KIND_USER_POST, title="Shared",
)
Line.objects.create(post=self.post, text="x", author=self.owner)
self.post.shared_with.add(self.invitee, self.other)
def test_invitee_redirects_to_my_posts(self):
self.client.force_login(self.invitee)
response = self.client.post(
reverse("billboard:abandon_post", args=[self.post.id])
)
self.assertRedirects(
response,
reverse("billboard:my_posts", args=[self.invitee.id]),
fetch_redirect_response=False,
)
def test_invitee_is_removed_from_shared_with(self):
self.client.force_login(self.invitee)
self.client.post(reverse("billboard:abandon_post", args=[self.post.id]))
self.post.refresh_from_db()
self.assertNotIn(self.invitee, self.post.shared_with.all())
def test_post_survives_invitee_abandonment(self):
self.client.force_login(self.invitee)
self.client.post(reverse("billboard:abandon_post", args=[self.post.id]))
self.post.refresh_from_db()
self.assertEqual(self.post.owner, self.owner)
self.assertIn(self.other, self.post.shared_with.all())
def test_get_does_not_remove(self):
self.client.force_login(self.invitee)
self.client.get(reverse("billboard:abandon_post", args=[self.post.id]))
self.post.refresh_from_db()
self.assertIn(self.invitee, self.post.shared_with.all())
def test_non_invitee_post_is_no_op(self):
random_user = User.objects.create(email="random@test.io")
self.client.force_login(random_user)
self.client.post(reverse("billboard:abandon_post", args=[self.post.id]))
self.post.refresh_from_db()
self.assertIn(self.invitee, self.post.shared_with.all())
self.assertIn(self.other, self.post.shared_with.all())
def test_note_unlock_post_is_protected(self):
# Admin Posts have no recipients to begin with, but harden the view
# so a forged BYE can't strip shared_with anyway.
admin_post = self.Post.objects.create(
owner=self.owner, kind=self.Post.KIND_NOTE_UNLOCK, title="Notes",
)
admin_post.shared_with.add(self.invitee)
self.client.force_login(self.invitee)
self.client.post(reverse("billboard:abandon_post", args=[admin_post.id]))
admin_post.refresh_from_db()
self.assertIn(self.invitee, admin_post.shared_with.all())
class AnonymousPostViewerTest(TestCase):
"""view_post has no @login_required (Percival ch.18 anonymous-post lab —
ownerless Posts are viewable by anyone). The gear menu's NVM target
reverses `my_posts user_id=request.user.id`, which previously exploded
w. NoReverseMatch when request.user.id was None (anonymous user).
Gear menu must be gated on is_authenticated; the post body still renders
for anonymous viewers of ownerless posts."""
def setUp(self):
from apps.billboard.models import Post
# Ownerless Post — matches the Percival anonymous-share contract
# exercised by apps.dashboard.tests.integrated.test_views.SharePostTest.
# No Line needed; the empty post still exercises the template render.
self.post = Post.objects.create(title="Public-ish")
def test_anonymous_can_view_ownerless_post_without_500(self):
response = self.client.get(
reverse("billboard:view_post", args=[self.post.id])
)
self.assertEqual(response.status_code, 200)
def test_anonymous_gets_no_gear_menu(self):
response = self.client.get(
reverse("billboard:view_post", args=[self.post.id])
)
self.assertNotIn(b"id_post_menu", response.content)
class MySignViewTest(TestCase):
"""Game Sign picker view at /billboard/my-sign/ — Sprint 4a of
[[project-my-sea-roadmap]]. Pins the GET render + POST save contract."""
def setUp(self):
self.user = User.objects.create(email="sign@test.io")
self.client.force_login(self.user)
_seed_billboard_applets()
def test_my_sign_requires_login(self):
self.client.logout()
response = self.client.get(reverse("billboard:my_sign"))
self.assertRedirects(
response, "/?next=/billboard/my-sign/", fetch_redirect_response=False,
)
def test_my_sign_renders_200(self):
response = self.client.get(reverse("billboard:my_sign"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/billboard/my_sign.html")
def test_my_sign_passes_18_card_pile_for_user_w_no_notes(self):
# The signal auto-equips Earthman; personal_sig_cards returns 16
# middle arcana courts (Majors 0/1 filtered out w.o Schizo/Nomad).
response = self.client.get(reverse("billboard:my_sign"))
self.assertEqual(len(response.context["cards"]), 16)
def test_save_sign_persists_card_and_reversed_flag(self):
from apps.epic.models import personal_sig_cards
target = personal_sig_cards(self.user)[0]
response = self.client.post(
reverse("billboard:save_sign"),
{"card_id": target.id, "reversed": "1"},
)
self.assertRedirects(response, reverse("billboard:my_sign"))
self.user.refresh_from_db()
self.assertEqual(self.user.significator_id, target.id)
self.assertTrue(self.user.significator_reversed)
def test_save_sign_rejects_invalid_card_id(self):
response = self.client.post(
reverse("billboard:save_sign"),
{"card_id": 999999, "reversed": "0"},
)
self.assertEqual(response.status_code, 403)
def test_page_carries_data_deck_polarized_attr(self):
"""Sprint A.5-polish — the my_sign page wrapper exposes the equipped
deck's `is_polarized` state via `data-deck-polarized` so the FLIP-btn
JS can branch: polarized decks cycle polarity (existing behavior);
non-polarized decks flip to the deck card-back (new)."""
import lxml.html
# Default Earthman = is_polarized=True per A.0 migration.
response = self.client.get(reverse("billboard:my_sign"))
parsed = lxml.html.fromstring(response.content)
[page] = parsed.cssselect(".my-sign-page")
self.assertEqual(page.get("data-deck-polarized"), "true")
def test_image_deck_renders_back_img_in_stage_scaffold(self):
"""Image-equipped non-polarized decks (Minchiate) render a hidden
<img.sig-stage-card-back-img> inside the stage card; toggled visible
by the FLIP-btn JS handler via the .is-flipped-to-back class."""
from apps.epic.models import DeckVariant
import lxml.html
minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
self.user.unlocked_decks.add(minchiate)
self.user.equipped_deck = minchiate
self.user.save(update_fields=["equipped_deck"])
response = self.client.get(reverse("billboard:my_sign"))
parsed = lxml.html.fromstring(response.content)
[page] = parsed.cssselect(".my-sign-page")
self.assertEqual(page.get("data-deck-polarized"), "false")
[back_img] = parsed.cssselect(".sig-stage-card .sig-stage-card-back-img")
self.assertIn(
"minchiate-fiorentine-1860-1890-back.png",
back_img.get("src", ""),
)
def test_polarized_deck_omits_back_img(self):
"""Earthman (polarized) keeps the existing polarity-cycle FLIP — no
back-image element needed in the scaffold."""
import lxml.html
response = self.client.get(reverse("billboard:my_sign"))
parsed = lxml.html.fromstring(response.content)
self.assertEqual(
len(parsed.cssselect(".sig-stage-card .sig-stage-card-back-img")), 0,
"Polarized deck must not render the back-image element",
)
self.user.refresh_from_db()
self.assertIsNone(self.user.significator_id)
def test_stat_block_renders_rank_suit_chip_per_face(self):
"""Sprint A.7.5 — `.stat-face-header` wraps the new top-left rank+suit
chip inline w. the EMANATION/REVERSAL label per [[project-image-based-
deck-face-rendering]]'s A.3 Q3 spec. Empty by default (JS-populated by
stage-card.js populateStatExtras on focus); both upright + reversed
faces carry their own chip slot so post-SPIN the chip stays visible."""
import lxml.html
response = self.client.get(reverse("billboard:my_sign"))
parsed = lxml.html.fromstring(response.content)
for face_cls in ("stat-face--upright", "stat-face--reversed"):
face = parsed.cssselect(f".sig-stat-block .{face_cls}")
self.assertEqual(len(face), 1, f"expected one {face_cls}")
[header] = face[0].cssselect(".stat-face-header")
# Polish-4 — header is a 2-row vertical stack: rank on row 1
# (direct child), icon+label inside `.stat-chip-tag` on row 2.
[_rank] = header.cssselect(".stat-chip-rank")
[_tag] = header.cssselect(".stat-chip-tag")
[_icon] = _tag.cssselect("i.stat-chip-icon")
[_label] = _tag.cssselect(".stat-face-label")
def test_save_sign_get_redirects_back_to_picker(self):
response = self.client.get(reverse("billboard:save_sign"))
self.assertRedirects(response, reverse("billboard:my_sign"))
class ClearSignViewTest(TestCase):
"""Clear-sign endpoint — POST `/billboard/my-sign/clear` wipes the
user's significator FK + reversed flag, then redirects to the picker.
Sprint 4b-adjacent (2026-05-19). See [[project-my-sea-roadmap]]."""
def setUp(self):
self.user = User.objects.create(email="clear@test.io")
self.client.force_login(self.user)
_seed_billboard_applets()
from apps.epic.models import personal_sig_cards
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.significator_reversed = True
self.user.save(update_fields=["significator", "significator_reversed"])
def test_clear_sign_requires_login(self):
self.client.logout()
response = self.client.post(reverse("billboard:clear_sign"))
self.assertRedirects(
response, "/?next=/billboard/my-sign/clear",
fetch_redirect_response=False,
)
def test_clear_sign_post_wipes_sig_and_reversed_flag(self):
response = self.client.post(reverse("billboard:clear_sign"))
self.assertRedirects(response, reverse("billboard:my_sign"))
self.user.refresh_from_db()
self.assertIsNone(self.user.significator_id)
self.assertFalse(self.user.significator_reversed)
def test_clear_sign_get_redirects_without_clearing(self):
response = self.client.get(reverse("billboard:clear_sign"))
self.assertRedirects(response, reverse("billboard:my_sign"))
self.user.refresh_from_db()
self.assertEqual(self.user.significator_id, self.target.id)
self.assertTrue(self.user.significator_reversed)
def test_clear_sign_post_with_no_existing_sig_is_noop(self):
self.user.significator = None
self.user.significator_reversed = False
self.user.save(update_fields=["significator", "significator_reversed"])
response = self.client.post(reverse("billboard:clear_sign"))
self.assertRedirects(response, reverse("billboard:my_sign"))
self.user.refresh_from_db()
self.assertIsNone(self.user.significator_id)
self.assertFalse(self.user.significator_reversed)
class MySignClearAffordanceTemplateTest(TestCase):
"""Pin the CLEAR SIGN btn template-render contract — visible only when
`user.significator` is set on the picker page."""
def setUp(self):
self.user = User.objects.create(email="ctmpl@test.io")
self.client.force_login(self.user)
_seed_billboard_applets()
def test_clear_btn_absent_when_no_sig_saved(self):
response = self.client.get(reverse("billboard:my_sign"))
self.assertNotContains(response, 'id="id_clear_sign_btn"')
def test_clear_btn_present_and_targets_clear_url_when_sig_saved(self):
from apps.epic.models import personal_sig_cards
target = personal_sig_cards(self.user)[0]
self.user.significator = target
self.user.save(update_fields=["significator"])
response = self.client.get(reverse("billboard:my_sign"))
self.assertContains(response, 'id="id_clear_sign_btn"')
self.assertContains(response, reverse("billboard:clear_sign"))
class BillboardAppletMySignTest(TestCase):
"""My Sign applet rendering on /billboard/."""
def setUp(self):
self.user = User.objects.create(email="apllet@test.io")
self.client.force_login(self.user)
_seed_billboard_applets()
def test_billboard_shows_my_sign_applet(self):
response = self.client.get("/billboard/")
self.assertContains(response, 'id="id_applet_my_sign"')
def test_my_sign_applet_renders_empty_state_when_no_sig(self):
response = self.client.get("/billboard/")
self.assertContains(response, "my-sign-applet-empty")
self.assertContains(response, "No sign chosen yet.")
self.assertNotContains(response, "my-sign-applet-card")
def test_my_sign_applet_renders_card_when_sig_set(self):
from apps.epic.models import personal_sig_cards
target = personal_sig_cards(self.user)[0]
self.user.significator = target
self.user.significator_reversed = True
self.user.save(update_fields=["significator", "significator_reversed"])
response = self.client.get("/billboard/")
self.assertContains(response, "my-sign-applet-card")
self.assertContains(response, f'data-card-id="{target.id}"')
# significator_reversed = True ↔ polarity=levity (per convention).
# Saved sigs are POLARITY-only — the orientation (SPIN) axis is not
# persisted, so the applet card renders upright with the levity
# polarity class, NOT rotated via `stage-card--reversed`.
self.assertContains(response, "my-sign-applet-card--levity")
self.assertNotContains(response, "stage-card--reversed")
# Polarity qualifier renders alongside the title (middle court →
# "Elevated" for levity, "Graven" for gravity).
self.assertContains(response, "fan-card-qualifier")
if target.levity_qualifier:
self.assertContains(response, target.levity_qualifier)
# Always the emanation face — keywords_upright + "Emanation" label.
self.assertContains(response, "Emanation")
self.assertNotContains(response, ">Reversal<")
def test_my_sign_applet_renders_gravity_qualifier_when_not_reversed(self):
from apps.epic.models import personal_sig_cards
target = personal_sig_cards(self.user)[0]
self.user.significator = target
self.user.significator_reversed = False
self.user.save(update_fields=["significator", "significator_reversed"])
response = self.client.get("/billboard/")
self.assertContains(response, "my-sign-applet-card--gravity")
if target.gravity_qualifier:
self.assertContains(response, target.gravity_qualifier)
def test_my_sign_applet_renders_image_when_deck_has_card_images(self):
"""Sprint A.6 — applet card carries `.my-sign-applet-card--image` +
an <img.sig-stage-card-img> child when the user's equipped deck is
image-equipped (Minchiate today). Shares the contour-stroke + depth
shadow SCSS w. my_sign.html's stage-card-image via comma-list selector.
Text scaffold (fan-card-corner / fan-card-face) is NOT rendered in
image mode — server-side template `{% if/else %}` branch."""
from apps.epic.models import DeckVariant, TarotCard
import lxml.html
minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
self.user.is_superuser = True
self.user.save()
from apps.drama.models import Note
Note.grant_if_new(self.user, "super-nomad")
Note.grant_if_new(self.user, "super-schizo")
self.user.unlocked_decks.add(minchiate)
self.user.equipped_deck = minchiate
il_matto = TarotCard.objects.get(deck_variant=minchiate, slug="il-matto")
self.user.significator = il_matto
self.user.save(update_fields=["equipped_deck", "significator"])
response = self.client.get("/billboard/")
parsed = lxml.html.fromstring(response.content)
[card_el] = parsed.cssselect(".my-sign-applet-card")
self.assertIn("my-sign-applet-card--image", card_el.get("class", ""))
self.assertEqual(card_el.get("data-arcana-key"), "MAJOR")
[img] = card_el.cssselect("img.sig-stage-card-img")
self.assertIn(
"minchiate-fiorentine-1860-1890-trumps-00-il-matto.png",
img.get("src", ""),
)
# Text scaffold absent in image mode (the server-side {% if %} branch
# skips the fan-card-corner + fan-card-face children entirely).
self.assertEqual(
len(card_el.cssselect(".fan-card-corner")), 0,
"Text scaffold must not render in image mode",
)
def test_my_sign_applet_keeps_text_render_for_non_image_deck(self):
"""Earthman (has_card_images=False) keeps the existing fan-card-corner
text scaffold + lacks the --image modifier class."""
from apps.epic.models import personal_sig_cards
target = personal_sig_cards(self.user)[0]
self.user.significator = target
self.user.save(update_fields=["significator"])
import lxml.html
response = self.client.get("/billboard/")
parsed = lxml.html.fromstring(response.content)
[card_el] = parsed.cssselect(".my-sign-applet-card")
self.assertNotIn("my-sign-applet-card--image", card_el.get("class", ""))
self.assertEqual(
len(card_el.cssselect("img.sig-stage-card-img")), 0,
"Non-image deck must not render the <img>",
)
self.assertGreater(
len(card_el.cssselect(".fan-card-corner")), 0,
"Non-image deck keeps the text scaffold",
)
def test_applet_stat_block_renders_server_side_chip(self):
"""Sprint A.7.5 — applet is read-only so the rank+suit chip is server-
rendered (not JS-populated as on stage / sea_stage / fan stage). Chip
carries the card's corner_rank + suit_icon FA class inline w. the
EMANATION label inside `.stat-face-header`."""
from apps.epic.models import personal_sig_cards
target = personal_sig_cards(self.user)[0]
self.user.significator = target
self.user.save(update_fields=["significator"])
import lxml.html
response = self.client.get("/billboard/")
parsed = lxml.html.fromstring(response.content)
[block] = parsed.cssselect(".my-sign-applet-stat-block")
[header] = block.cssselect(".stat-face-header")
# Polish-4 — rank is a direct child of header (own row); icon lives
# inside `.stat-chip-tag` (row-2 inline w. the EMANATION label).
[rank] = header.cssselect(".stat-chip-rank")
# Court middle cards have single-letter corner ranks (M/J/Q/K) per
# TarotCard.corner_rank — pin presence, not the exact value (which
# depends on which middle court personal_sig_cards returns first).
self.assertTrue(rank.text and rank.text.strip())
[tag] = header.cssselect(".stat-chip-tag")
[icon] = tag.cssselect("i.stat-chip-icon")
# 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 *live* SeaInvite
exists from this bud (owner) to the viewer (invitee) — non-terminal
(PENDING or ACCEPTED) AND inside its 24h-from-proffer window OR within 24h
of the viewer's last gate token deposit (user-spec 2026-05-29, via
`SeaInvite.invitee_access_open`). 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_within_window_lights_cascade(self):
# New spec: an ACCEPTED invite still inside its 24h window keeps the
# cascade lit (the old design went dark the instant it accepted, so
# the user could never reach the bud's sea from here post-accept).
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.assertIsNotNone(response.context["pending_invite"])
self.assertTrue(response.context["sea_btn_active"])
self.assertTrue(response.context["sea_first_draw_pending"])
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_stale_accepted_without_deposit_does_not_cascade(self):
inv = self.SeaInvite.objects.create(
owner=self.alice,
invitee=self.user,
invitee_email=self.user.email,
status=self.SeaInvite.ACCEPTED,
)
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_recent_deposit_relights_cascade_past_invite_window(self):
# Invite proffered 3 days ago but a gate token deposit 5h ago re-arms
# the 24h window — the "OR 24h since last token deposit" clause.
inv = self.SeaInvite.objects.create(
owner=self.alice,
invitee=self.user,
invitee_email=self.user.email,
status=self.SeaInvite.ACCEPTED,
token_deposited_at=timezone.now() - timezone.timedelta(hours=5),
)
self.SeaInvite.objects.filter(pk=inv.pk).update(
created_at=timezone.now() - timezone.timedelta(hours=72),
)
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"])
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)