new applet structure for apps.billboard, incl. My Scrolls, Contacts & Most Recent applets; completely revamped _billboard.scss, tho some styling inconsistencies persist; ensured #id_billboard_applets_container inherited base styles found in _applets.scss; a pair of new migrations in apps.applets to support new applet models & fields; billboard gets its first ITs, new urls & views; pair of new FT classes in FTs.test_billboard

This commit is contained in:
Disco DeDisco
2026-03-24 16:46:46 -04:00
parent 18898c7a0f
commit 189d329e76
18 changed files with 492 additions and 31 deletions

View File

@@ -0,0 +1,48 @@
from django.db import migrations, models
def seed_billboard_applets(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
for slug, name, cols, rows in [
("billboard-my-scrolls", "My Scrolls", 4, 3),
("billboard-my-contacts", "Contacts", 4, 3),
("billboard-most-recent", "Most Recent", 8, 6),
]:
Applet.objects.get_or_create(
slug=slug,
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
)
def remove_billboard_applets(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.filter(slug__in=[
"billboard-my-scrolls",
"billboard-my-contacts",
"billboard-most-recent",
]).delete()
class Migration(migrations.Migration):
dependencies = [
("applets", "0005_gameboard_applet_heights"),
]
operations = [
migrations.AlterField(
model_name="applet",
name="context",
field=models.CharField(
choices=[
("dashboard", "Dashboard"),
("gameboard", "Gameboard"),
("wallet", "Wallet"),
("billboard", "Billboard"),
],
default="dashboard",
max_length=20,
),
),
migrations.RunPython(seed_billboard_applets, remove_billboard_applets),
]

View File

@@ -0,0 +1,29 @@
from django.db import migrations
def fix_billboard_applets(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
# billboard-scroll belongs only to the billscroll page template, not the grid
Applet.objects.filter(slug="billboard-scroll").delete()
# Rename "My Contacts" → "Contacts"
Applet.objects.filter(slug="billboard-my-contacts").update(name="Contacts")
def reverse_fix_billboard_applets(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.get_or_create(
slug="billboard-scroll",
defaults={"name": "Billscroll", "grid_cols": 12, "grid_rows": 6, "context": "billboard"},
)
Applet.objects.filter(slug="billboard-my-contacts").update(name="My Contacts")
class Migration(migrations.Migration):
dependencies = [
("applets", "0006_billboard_applets"),
]
operations = [
migrations.RunPython(fix_billboard_applets, reverse_fix_billboard_applets),
]

View File

@@ -4,10 +4,12 @@ class Applet(models.Model):
DASHBOARD = "dashboard" DASHBOARD = "dashboard"
GAMEBOARD = "gameboard" GAMEBOARD = "gameboard"
WALLET = "wallet" WALLET = "wallet"
BILLBOARD = "billboard"
CONTEXT_CHOICES = [ CONTEXT_CHOICES = [
(DASHBOARD, "Dashboard"), (DASHBOARD, "Dashboard"),
(GAMEBOARD, "Gameboard"), (GAMEBOARD, "Gameboard"),
(WALLET, "Wallet"), (WALLET, "Wallet"),
(BILLBOARD, "Billboard"),
] ]
slug = models.SlugField(unique=True) slug = models.SlugField(unique=True)

View File

View File

@@ -0,0 +1,111 @@
from django.test import TestCase
from django.urls import reverse
from apps.applets.models import Applet
from apps.drama.models import GameEvent, record
from apps.epic.models import Room
from apps.lyric.models import User
def _seed_billboard_applets():
for slug, name, cols, rows in [
("billboard-my-scrolls", "My Scrolls", 4, 3),
("billboard-my-contacts", "Contacts", 4, 3),
("billboard-most-recent", "Most Recent", 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("billboard-my-scrolls", slugs)
self.assertIn("billboard-my-contacts", slugs)
self.assertIn("billboard-most-recent", 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_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 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": ["billboard-my-scrolls"]},
)
self.assertEqual(response.status_code, 302)
from apps.applets.models import UserApplet
contacts = Applet.objects.get(slug="billboard-my-contacts")
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": ["billboard-my-scrolls"]},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/billboard/_partials/_applets.html")
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_room_scroll_template(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertTemplateUsed(response, "apps/billboard/room_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")

View File

@@ -6,5 +6,6 @@ app_name = "billboard"
urlpatterns = [ urlpatterns = [
path("", views.billboard, name="billboard"), path("", views.billboard, name="billboard"),
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"), path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"),
] ]

View File

@@ -1,7 +1,9 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Q from django.db.models import Max, Q
from django.shortcuts import render from django.shortcuts import redirect, render
from apps.applets.models import Applet, UserApplet
from apps.applets.utils import applet_context
from apps.drama.models import GameEvent from apps.drama.models import GameEvent
from apps.epic.models import GateSlot, Room, RoomInvite from apps.epic.models import GateSlot, Room, RoomInvite
@@ -13,12 +15,48 @@ def billboard(request):
Q(gate_slots__gamer=request.user) | Q(gate_slots__gamer=request.user) |
Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING) Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING)
).distinct().order_by("-created_at") ).distinct().order_by("-created_at")
recent_room = (
Room.objects.filter(
Q(owner=request.user) | Q(gate_slots__gamer=request.user)
)
.annotate(last_event=Max("events__timestamp"))
.filter(last_event__isnull=False)
.order_by("-last_event")
.distinct()
.first()
)
recent_events = (
recent_room.events.select_related("actor").order_by("-timestamp")[:10]
if recent_room else []
)
return render(request, "apps/billboard/billboard.html", { return render(request, "apps/billboard/billboard.html", {
"my_rooms": my_rooms, "my_rooms": my_rooms,
"recent_room": recent_room,
"recent_events": recent_events,
"viewer": request.user,
"applets": applet_context(request.user, "billboard"),
"page_class": "page-billboard", "page_class": "page-billboard",
}) })
@login_required(login_url="/")
def toggle_billboard_applets(request):
checked = request.POST.getlist("applets")
for applet in Applet.objects.filter(context="billboard"):
UserApplet.objects.update_or_create(
user=request.user,
applet=applet,
defaults={"visible": applet.slug in checked},
)
if request.headers.get("HX-Request"):
return render(request, "apps/billboard/_partials/_applets.html", {
"applets": applet_context(request.user, "billboard"),
})
return redirect("billboard:billboard")
@login_required(login_url="/") @login_required(login_url="/")
def room_scroll(request, room_id): def room_scroll(request, room_id):
room = Room.objects.get(id=room_id) room = Room.objects.get(id=room_id)
@@ -27,5 +65,5 @@ def room_scroll(request, room_id):
"room": room, "room": room,
"events": events, "events": events,
"viewer": request.user, "viewer": request.user,
"page_class": "page-billboard", "page_class": "page-billscroll",
}) })

View File

@@ -1,6 +1,7 @@
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from .base import FunctionalTest from .base import FunctionalTest
from apps.applets.models import Applet
from apps.drama.models import GameEvent, record from apps.drama.models import GameEvent, record
from apps.epic.models import Room, GateSlot from apps.epic.models import Room, GateSlot
from apps.lyric.models import User from apps.lyric.models import User
@@ -18,8 +19,17 @@ class BillboardScrollTest(FunctionalTest):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.founder = User.objects.create(email="founder@scroll.io") for slug, name, cols, rows in [
self.other = User.objects.create(email="other@scroll.io") ("billboard-my-scrolls", "My Scrolls", 4, 3),
("billboard-my-contacts", "Contacts", 4, 3),
("billboard-most-recent", "Most Recent", 8, 6),
]:
Applet.objects.get_or_create(
slug=slug,
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
)
self.founder = User.objects.create(email="founder@test.io")
self.other = User.objects.create(email="other@test.io")
self.room = Room.objects.create(name="Blissful Ignorance", owner=self.founder) self.room = Room.objects.create(name="Blissful Ignorance", owner=self.founder)
# Simulate two gate fills — one by founder, one by the other gamer # Simulate two gate fills — one by founder, one by the other gamer
record( record(
@@ -44,7 +54,7 @@ class BillboardScrollTest(FunctionalTest):
def test_footer_scroll_icon_leads_to_billboard_with_rooms(self): def test_footer_scroll_icon_leads_to_billboard_with_rooms(self):
# Founder logs in and lands on the dashboard # Founder logs in and lands on the dashboard
self.create_pre_authenticated_session("founder@scroll.io") self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + "/") self.browser.get(self.live_server_url + "/")
# Footer contains a scroll icon link pointing to /billboard/ # Footer contains a scroll icon link pointing to /billboard/
@@ -65,7 +75,7 @@ class BillboardScrollTest(FunctionalTest):
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_scroll_shows_human_readable_event_log(self): def test_scroll_shows_human_readable_event_log(self):
self.create_pre_authenticated_session("founder@scroll.io") self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + "/billboard/") self.browser.get(self.live_server_url + "/billboard/")
# Click the room link to reach the scroll # Click the room link to reach the scroll
@@ -93,7 +103,7 @@ class BillboardScrollTest(FunctionalTest):
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_scroll_aligns_own_events_right_and_others_left(self): def test_scroll_aligns_own_events_right_and_others_left(self):
self.create_pre_authenticated_session("founder@scroll.io") self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + "/billboard/") self.browser.get(self.live_server_url + "/billboard/")
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.LINK_TEXT, "Blissful Ignorance") lambda: self.browser.find_element(By.LINK_TEXT, "Blissful Ignorance")
@@ -111,3 +121,102 @@ class BillboardScrollTest(FunctionalTest):
# The other gamer's event mentions their display name # The other gamer's event mentions their display name
self.assertIn("other", theirs_events[0].text) self.assertIn("other", theirs_events[0].text)
class BillboardAppletsTest(FunctionalTest):
"""
FT: billboard page renders three applets in the grid — My Scrolls,
My Contacts, and Most Recent — with a functioning gear menu.
"""
def setUp(self):
super().setUp()
self.founder = User.objects.create(email="founder@test.io")
self.room = Room.objects.create(name="Arcane Assembly", owner=self.founder)
for slug, name, cols, rows in [
("billboard-my-scrolls", "My Scrolls", 4, 3),
("billboard-my-contacts", "Contacts", 4, 3),
("billboard-most-recent", "Most Recent", 8, 6),
]:
Applet.objects.get_or_create(
slug=slug,
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
)
def test_billboard_shows_three_applets(self):
# 1. Log in, navigate to billboard
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + "/billboard/")
# 2. Assert all three applet sections present
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_billboard_my_scrolls")
)
self.browser.find_element(By.ID, "id_applet_billboard_my_contacts")
self.browser.find_element(By.ID, "id_applet_billboard_most_recent")
def test_billboard_my_scrolls_lists_rooms(self):
# 1. Log in, navigate to billboard
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + "/billboard/")
# 2. My Scrolls applet contains a link to the room's scroll
self.wait_for(
lambda: self.assertIn(
"Arcane Assembly",
self.browser.find_element(By.ID, "id_applet_billboard_my_scrolls").text,
)
)
def test_billboard_gear_btn_opens_applet_menu(self):
# 1. Log in, navigate to billboard
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + "/billboard/")
# 2. Gear button is visible
gear_btn = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".billboard-page .gear-btn")
)
# 3. Menu is hidden before click
menu = self.browser.find_element(By.ID, "id_billboard_applet_menu")
self.assertFalse(menu.is_displayed())
# 4. Clicking gear opens the menu (JS click bypasses kit-bag overlap in headless)
self.browser.execute_script("arguments[0].click()", gear_btn)
self.wait_for_slow(lambda: self.assertTrue(menu.is_displayed()))
class BillscrollAppletsTest(FunctionalTest):
"""
FT: billscroll page renders as a single full-width applet that fills
the viewport aperture and contains the room's drama events.
"""
def setUp(self):
super().setUp()
self.founder = User.objects.create(email="founder@billtest.io")
self.room = Room.objects.create(name="Spectral Council", owner=self.founder)
record(
self.room, GameEvent.SLOT_FILLED, actor=self.founder,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
def test_billscroll_shows_full_width_applet(self):
# 1. Log in, navigate to the room's scroll
self.create_pre_authenticated_session("founder@billtest.io")
self.browser.get(
self.live_server_url + f"/billboard/room/{self.room.id}/scroll/"
)
# 2. The full-width applet section is present
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_billboard_scroll")
)
def test_billscroll_applet_contains_drama_events(self):
# 1. Log in, navigate to the room's scroll
self.create_pre_authenticated_session("founder@billtest.io")
self.browser.get(
self.live_server_url + f"/billboard/room/{self.room.id}/scroll/"
)
# 2. Drama scroll is inside the applet and shows the event
scroll = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
)
self.assertIn("Coin-on-a-String", scroll.text)

View File

@@ -205,6 +205,7 @@
} }
} }
#id_applets_container { @extend %applets-grid; } #id_applets_container { @extend %applets-grid; }
#id_game_applets_container { @extend %applets-grid; } #id_game_applets_container { @extend %applets-grid; }
#id_wallet_applets_container { @extend %applets-grid; } #id_wallet_applets_container { @extend %applets-grid; }
#id_billboard_applets_container { @extend %applets-grid; }

View File

@@ -1,8 +1,21 @@
html:has(body.page-billboard) { // ── Shared aperture fill for both billboard pages ──────────────────────────
%billboard-page-base {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
position: relative;
padding: 0.75rem;
}
html:has(body.page-billboard),
html:has(body.page-billscroll) {
overflow: hidden; overflow: hidden;
} }
body.page-billboard { body.page-billboard,
body.page-billscroll {
overflow: hidden; overflow: hidden;
.container { .container {
@@ -19,10 +32,61 @@ body.page-billboard {
} }
} }
// ── Billboard page (three-applet grid) ─────────────────────────────────────
.billboard-page { .billboard-page {
flex: 1; @extend %billboard-page-base;
min-width: 0;
overflow-y: auto; // Gear btn positioning mirrors dashboard/wallet pattern
position: relative; > .gear-btn {
padding: 0.75rem; position: fixed;
bottom: calc(var(--footer-w, 4rem) + 0.75rem);
right: calc(var(--footer-w, 4rem) + 0.75rem);
z-index: 10;
}
}
// ── Billscroll page (single full-aperture applet) ──────────────────────────
.billscroll-page {
@extend %billboard-page-base;
display: flex;
flex-direction: column;
padding: 0.75rem;
// The single scroll applet stretches to fill the remaining aperture
.applet-billboard-scroll {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
// Override grid-row span — this applet fills height via flex, not grid rows
grid-row: unset;
#id_drama_scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
}
}
// ── My Scrolls list ────────────────────────────────────────────────────────
#id_applet_billboard_my_scrolls {
.scroll-list {
list-style: none;
padding: 0;
margin: 0;
overflow-y: auto;
li {
padding: 0.25rem 0;
border-bottom: 1px solid rgba(var(--priUser), 0.15);
&:last-child { border-bottom: none; }
a { text-decoration: none; }
}
}
} }

View File

@@ -0,0 +1,14 @@
<section
id="id_applet_billboard_most_recent"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2>Most Recent</h2>
{% if recent_room %}
<a href="{% url 'billboard:scroll' recent_room.id %}" class="most-recent-room-link">{{ recent_room.name }}</a>
{% with events=recent_events %}
{% include "core/_partials/_scroll.html" %}
{% endwith %}
{% else %}
<p><small>No recent activity.</small></p>
{% endif %}
</section>

View File

@@ -0,0 +1,7 @@
<section
id="id_applet_billboard_my_contacts"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2>Contacts</h2>
<p><small>Coming soon.</small></p>
</section>

View File

@@ -0,0 +1,15 @@
<section
id="id_applet_billboard_my_scrolls"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2>My Scrolls</h2>
<ul class="scroll-list">
{% for room in my_rooms %}
<li>
<a href="{% url 'billboard:scroll' room.id %}">{{ room.name }}</a>
</li>
{% empty %}
<li><small>No scrolls yet.</small></li>
{% endfor %}
</ul>
</section>

View File

@@ -0,0 +1,4 @@
<section id="id_applet_billboard_scroll" class="applet-billboard-scroll">
<h2>{{ room.name }}</h2>
{% include "core/_partials/_scroll.html" %}
</section>

View File

@@ -0,0 +1,27 @@
<div id="id_billboard_applets_container">
<div id="id_billboard_applet_menu" style="display:none;">
<form
hx-post="{% url "billboard:toggle_applets" %}"
hx-target="#id_billboard_applets_container"
hx-swap="outerHTML"
>
{% csrf_token %}
{% for entry in applets %}
<label>
<input
type="checkbox"
name="applets"
value="{{ entry.applet.slug }}"
{% if entry.visible %}checked{% endif %}
>
{{ entry.applet.name }}
</label>
{% endfor %}
<div class="menu-btns">
<button type="submit" class="btn btn-confirm">OK</button>
<button type="button" class="btn btn-cancel applet-menu-cancel">NVM</button>
</div>
</form>
</div>
{% include "apps/applets/_partials/_applets.html" %}
</div>

View File

@@ -5,15 +5,7 @@
{% block content %} {% block content %}
<div class="billboard-page"> <div class="billboard-page">
<h2>My Scrolls</h2> {% include "apps/applets/_partials/_gear.html" with menu_id="id_billboard_applet_menu" %}
<ul class="game-list"> {% include "apps/billboard/_partials/_applets.html" %}
{% for room in my_rooms %}
<li>
<a href="{% url 'billboard:scroll' room.id %}">{{ room.name }}</a>
</li>
{% empty %}
<li><small>No scrolls yet.</small></li>
{% endfor %}
</ul>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -4,8 +4,7 @@
{% block header_text %}<span>Bill</span>scroll{% endblock header_text %} {% block header_text %}<span>Bill</span>scroll{% endblock header_text %}
{% block content %} {% block content %}
<div class="billboard-page"> <div class="billscroll-page">
<h2>{{ room.name }}</h2> {% include "apps/billboard/_partials/_applet-billboard-scroll.html" %}
{% include "core/_partials/_scroll.html" %}
</div> </div>
{% endblock %} {% endblock %}