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), ]: 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"]), []) 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*<', )