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>
1532 lines
66 KiB
Python
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)
|