new DRAMA & BILLBOARD apps to start provenance system; new billboard.html & _scroll.html templates; admin area now displays game event log; new CLAUDE.md file to free up Claude Code's memory.md space; minor additions to apps.epic.views to ensure new systems just described adhere to existing game views
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
0
src/apps/billboard/__init__.py
Normal file
0
src/apps/billboard/__init__.py
Normal file
6
src/apps/billboard/apps.py
Normal file
6
src/apps/billboard/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BillboardConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.billboard'
|
||||
10
src/apps/billboard/urls.py
Normal file
10
src/apps/billboard/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from apps.billboard import views
|
||||
|
||||
app_name = "billboard"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.billboard, name="billboard"),
|
||||
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"),
|
||||
]
|
||||
31
src/apps/billboard/views.py
Normal file
31
src/apps/billboard/views.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import render
|
||||
|
||||
from apps.drama.models import GameEvent
|
||||
from apps.epic.models import GateSlot, Room, RoomInvite
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def billboard(request):
|
||||
my_rooms = Room.objects.filter(
|
||||
Q(owner=request.user) |
|
||||
Q(gate_slots__gamer=request.user) |
|
||||
Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING)
|
||||
).distinct().order_by("-created_at")
|
||||
return render(request, "apps/billboard/billboard.html", {
|
||||
"my_rooms": my_rooms,
|
||||
"page_class": "page-billboard",
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def room_scroll(request, room_id):
|
||||
room = Room.objects.get(id=room_id)
|
||||
events = room.events.select_related("actor").all()
|
||||
return render(request, "apps/billboard/room_scroll.html", {
|
||||
"room": room,
|
||||
"events": events,
|
||||
"viewer": request.user,
|
||||
"page_class": "page-billboard",
|
||||
})
|
||||
0
src/apps/drama/__init__.py
Normal file
0
src/apps/drama/__init__.py
Normal file
19
src/apps/drama/admin.py
Normal file
19
src/apps/drama/admin.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.drama.models import GameEvent
|
||||
|
||||
|
||||
@admin.register(GameEvent)
|
||||
class GameEventAdmin(admin.ModelAdmin):
|
||||
list_display = ("timestamp", "room", "actor", "verb")
|
||||
list_filter = ("verb",)
|
||||
readonly_fields = ("room", "actor", "verb", "data", "timestamp")
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
6
src/apps/drama/apps.py
Normal file
6
src/apps/drama/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DramaConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.drama'
|
||||
32
src/apps/drama/migrations/0001_initial.py
Normal file
32
src/apps/drama/migrations/0001_initial.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 6.0 on 2026-03-19 18:34
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('epic', '0006_table_status_and_table_seat'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='GameEvent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('verb', models.CharField(choices=[('room_created', 'Room created'), ('slot_reserved', 'Gate slot reserved'), ('slot_filled', 'Gate slot filled'), ('slot_returned', 'Gate slot returned'), ('slot_released', 'Gate slot released'), ('invite_sent', 'Invite sent'), ('role_select_started', 'Role select started'), ('role_selected', 'Role selected'), ('roles_revealed', 'Roles revealed')], max_length=30)),
|
||||
('data', models.JSONField(default=dict)),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='game_events', to=settings.AUTH_USER_MODEL)),
|
||||
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='epic.room')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['timestamp'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
src/apps/drama/migrations/__init__.py
Normal file
0
src/apps/drama/migrations/__init__.py
Normal file
88
src/apps/drama/models.py
Normal file
88
src/apps/drama/models.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class GameEvent(models.Model):
|
||||
# Gate phase
|
||||
ROOM_CREATED = "room_created"
|
||||
SLOT_RESERVED = "slot_reserved"
|
||||
SLOT_FILLED = "slot_filled"
|
||||
SLOT_RETURNED = "slot_returned"
|
||||
SLOT_RELEASED = "slot_released"
|
||||
INVITE_SENT = "invite_sent"
|
||||
# Role Select phase
|
||||
ROLE_SELECT_STARTED = "role_select_started"
|
||||
ROLE_SELECTED = "role_selected"
|
||||
ROLES_REVEALED = "roles_revealed"
|
||||
|
||||
VERB_CHOICES = [
|
||||
(ROOM_CREATED, "Room created"),
|
||||
(SLOT_RESERVED, "Gate slot reserved"),
|
||||
(SLOT_FILLED, "Gate slot filled"),
|
||||
(SLOT_RETURNED, "Gate slot returned"),
|
||||
(SLOT_RELEASED, "Gate slot released"),
|
||||
(INVITE_SENT, "Invite sent"),
|
||||
(ROLE_SELECT_STARTED, "Role select started"),
|
||||
(ROLE_SELECTED, "Role selected"),
|
||||
(ROLES_REVEALED, "Roles revealed"),
|
||||
]
|
||||
|
||||
room = models.ForeignKey(
|
||||
"epic.Room", on_delete=models.CASCADE, related_name="events",
|
||||
)
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||
on_delete=models.SET_NULL, related_name="game_events",
|
||||
)
|
||||
verb = models.CharField(max_length=30, choices=VERB_CHOICES)
|
||||
data = models.JSONField(default=dict)
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["timestamp"]
|
||||
|
||||
def to_prose(self):
|
||||
"""Return a human-readable action description (actor rendered separately in template)."""
|
||||
d = self.data
|
||||
if self.verb == self.SLOT_FILLED:
|
||||
_token_names = {
|
||||
"coin": "Coin-on-a-String", "Free": "Free Token",
|
||||
"tithe": "Tithe Token", "pass": "Backstage Pass", "carte": "Carte Blanche",
|
||||
}
|
||||
code = d.get("token_type", "token")
|
||||
token = d.get("token_display") or _token_names.get(code, code)
|
||||
days = d.get("renewal_days", 7)
|
||||
slot = d.get("slot_number", "?")
|
||||
return f"deposits a {token} for slot {slot} ({days} days)"
|
||||
if self.verb == self.SLOT_RESERVED:
|
||||
return "reserves a seat"
|
||||
if self.verb == self.SLOT_RETURNED:
|
||||
return "withdraws from the gate"
|
||||
if self.verb == self.SLOT_RELEASED:
|
||||
return f"releases slot {d.get('slot_number', '?')}"
|
||||
if self.verb == self.ROOM_CREATED:
|
||||
return "opens this room"
|
||||
if self.verb == self.INVITE_SENT:
|
||||
return "sends an invitation"
|
||||
if self.verb == self.ROLE_SELECT_STARTED:
|
||||
return "Role selection begins"
|
||||
if self.verb == self.ROLE_SELECTED:
|
||||
_role_names = {
|
||||
"PC": "Player", "BC": "Builder", "SC": "Shepherd",
|
||||
"AC": "Alchemist", "NC": "Narrator", "EC": "Economist",
|
||||
}
|
||||
code = d.get("role", "?")
|
||||
role = d.get("role_display") or _role_names.get(code, code)
|
||||
return f"starts as {role}"
|
||||
if self.verb == self.ROLES_REVEALED:
|
||||
return "All roles assigned"
|
||||
return self.verb
|
||||
|
||||
def __str__(self):
|
||||
actor = self.actor.email if self.actor else "system"
|
||||
return f"[{self.timestamp:%Y-%m-%d %H:%M}] {actor} → {self.verb}"
|
||||
|
||||
|
||||
def record(room, verb, actor=None, **data):
|
||||
"""Record a game event in the drama log."""
|
||||
return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)
|
||||
0
src/apps/drama/tests/__init__.py
Normal file
0
src/apps/drama/tests/__init__.py
Normal file
0
src/apps/drama/tests/integrated/__init__.py
Normal file
0
src/apps/drama/tests/integrated/__init__.py
Normal file
77
src/apps/drama/tests/integrated/test_views.py
Normal file
77
src/apps/drama/tests/integrated/test_views.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.drama.models import GameEvent
|
||||
from apps.epic.models import GateSlot, Room, TableSeat
|
||||
from apps.lyric.models import Token, User
|
||||
|
||||
|
||||
class ConfirmTokenRecordsSlotFilledTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="gamer@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
self.token = Token.objects.create(user=self.user, token_type=Token.TITHE)
|
||||
self.slot = self.room.gate_slots.get(slot_number=1)
|
||||
self.slot.gamer = self.user
|
||||
self.slot.status = GateSlot.RESERVED
|
||||
self.slot.reserved_at = timezone.now()
|
||||
self.slot.save()
|
||||
|
||||
def test_confirm_token_records_slot_filled_event(self):
|
||||
session = self.client.session
|
||||
session["kit_token_id"] = str(self.token.id)
|
||||
session.save()
|
||||
self.client.post(reverse("epic:confirm_token", args=[self.room.id]))
|
||||
event = GameEvent.objects.get(room=self.room, verb=GameEvent.SLOT_FILLED)
|
||||
self.assertEqual(event.actor, self.user)
|
||||
self.assertEqual(event.data["slot_number"], 1)
|
||||
self.assertEqual(event.data["token_type"], Token.TITHE)
|
||||
|
||||
def test_no_event_recorded_if_no_reserved_slot(self):
|
||||
self.slot.gamer = None
|
||||
self.slot.status = GateSlot.EMPTY
|
||||
self.slot.save()
|
||||
self.client.post(reverse("epic:confirm_token", args=[self.room.id]))
|
||||
self.assertEqual(GameEvent.objects.filter(verb=GameEvent.SLOT_FILLED).count(), 0)
|
||||
|
||||
|
||||
class SelectRoleRecordsRoleSelectedTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="player@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.room = Room.objects.create(
|
||||
name="Role Room", owner=self.user, table_status=Room.ROLE_SELECT
|
||||
)
|
||||
self.seat = TableSeat.objects.create(
|
||||
room=self.room, gamer=self.user, slot_number=1
|
||||
)
|
||||
|
||||
def test_select_role_records_role_selected_event(self):
|
||||
self.client.post(
|
||||
reverse("epic:select_role", args=[self.room.id]),
|
||||
data={"role": "PC"},
|
||||
)
|
||||
event = GameEvent.objects.get(room=self.room, verb=GameEvent.ROLE_SELECTED)
|
||||
self.assertEqual(event.actor, self.user)
|
||||
self.assertEqual(event.data["role"], "PC")
|
||||
self.assertEqual(event.data["slot_number"], 1)
|
||||
|
||||
def test_roles_revealed_event_recorded_when_all_seats_assigned(self):
|
||||
# Only one seat — assigning it triggers roles_revealed
|
||||
self.client.post(
|
||||
reverse("epic:select_role", args=[self.room.id]),
|
||||
data={"role": "PC"},
|
||||
)
|
||||
self.assertTrue(
|
||||
GameEvent.objects.filter(room=self.room, verb=GameEvent.ROLES_REVEALED).exists()
|
||||
)
|
||||
|
||||
def test_no_event_if_role_already_taken(self):
|
||||
TableSeat.objects.create(room=self.room, gamer=self.user, slot_number=2, role="PC")
|
||||
self.client.post(
|
||||
reverse("epic:select_role", args=[self.room.id]),
|
||||
data={"role": "PC"},
|
||||
)
|
||||
self.assertEqual(GameEvent.objects.filter(verb=GameEvent.ROLE_SELECTED).count(), 0)
|
||||
0
src/apps/drama/tests/unit/__init__.py
Normal file
0
src/apps/drama/tests/unit/__init__.py
Normal file
44
src/apps/drama/tests/unit/test_models.py
Normal file
44
src/apps/drama/tests/unit/test_models.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.drama.models import GameEvent, record
|
||||
from apps.epic.models import Room
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class GameEventModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="actor@test.io")
|
||||
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
|
||||
def test_record_creates_game_event(self):
|
||||
event = record(self.room, GameEvent.SLOT_FILLED, actor=self.user, slot_number=1, token_type="tithe")
|
||||
self.assertEqual(GameEvent.objects.count(), 1)
|
||||
self.assertEqual(event.room, self.room)
|
||||
self.assertEqual(event.actor, self.user)
|
||||
self.assertEqual(event.verb, GameEvent.SLOT_FILLED)
|
||||
self.assertEqual(event.data, {"slot_number": 1, "token_type": "tithe"})
|
||||
|
||||
def test_record_without_actor(self):
|
||||
event = record(self.room, GameEvent.ROOM_CREATED)
|
||||
self.assertIsNone(event.actor)
|
||||
self.assertEqual(event.verb, GameEvent.ROOM_CREATED)
|
||||
|
||||
def test_events_ordered_by_timestamp(self):
|
||||
record(self.room, GameEvent.ROOM_CREATED)
|
||||
record(self.room, GameEvent.SLOT_RESERVED, actor=self.user)
|
||||
record(self.room, GameEvent.SLOT_FILLED, actor=self.user)
|
||||
verbs = list(GameEvent.objects.values_list("verb", flat=True))
|
||||
self.assertEqual(verbs, [
|
||||
GameEvent.ROOM_CREATED,
|
||||
GameEvent.SLOT_RESERVED,
|
||||
GameEvent.SLOT_FILLED,
|
||||
])
|
||||
|
||||
def test_str_includes_actor_and_verb(self):
|
||||
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user, role="PC")
|
||||
self.assertIn("actor@test.io", str(event))
|
||||
self.assertIn(GameEvent.ROLE_SELECTED, str(event))
|
||||
|
||||
def test_str_without_actor_shows_system(self):
|
||||
event = record(self.room, GameEvent.ROLES_REVEALED)
|
||||
self.assertIn("system", str(event))
|
||||
@@ -7,6 +7,7 @@ from django.http import HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.drama.models import GameEvent, record
|
||||
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat, debit_token, select_token
|
||||
from apps.lyric.models import Token
|
||||
|
||||
@@ -261,6 +262,10 @@ def confirm_token(request, room_id):
|
||||
if int(slot_number) > carte.slots_claimed:
|
||||
carte.slots_claimed = int(slot_number)
|
||||
carte.save()
|
||||
record(room, GameEvent.SLOT_FILLED, actor=request.user,
|
||||
slot_number=int(slot_number), token_type=Token.CARTE,
|
||||
token_display=carte.get_token_type_display(),
|
||||
renewal_days=(room.renewal_period.days if room.renewal_period else 7))
|
||||
_notify_gate_update(room_id)
|
||||
else:
|
||||
slot = room.gate_slots.filter(
|
||||
@@ -275,6 +280,10 @@ def confirm_token(request, room_id):
|
||||
token = select_token(request.user)
|
||||
if token:
|
||||
debit_token(request.user, slot, token)
|
||||
record(room, GameEvent.SLOT_FILLED, actor=request.user,
|
||||
slot_number=slot.slot_number, token_type=token.token_type,
|
||||
token_display=token.get_token_type_display(),
|
||||
renewal_days=(room.renewal_period.days if room.renewal_period else 7))
|
||||
_notify_gate_update(room_id)
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
|
||||
@@ -378,11 +387,15 @@ def select_role(request, room_id):
|
||||
return HttpResponse(status=409)
|
||||
active_seat.role = role
|
||||
active_seat.save()
|
||||
record(room, GameEvent.ROLE_SELECTED, actor=request.user,
|
||||
role=role, slot_number=active_seat.slot_number,
|
||||
role_display=dict(TableSeat.ROLE_CHOICES).get(role, role))
|
||||
if room.table_seats.filter(role__isnull=True).exists():
|
||||
_notify_turn_changed(room_id)
|
||||
else:
|
||||
room.table_status = Room.SIG_SELECT
|
||||
room.save()
|
||||
record(room, GameEvent.ROLES_REVEALED)
|
||||
_notify_roles_revealed(room_id)
|
||||
return HttpResponse(status=200)
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
|
||||
@@ -60,6 +60,8 @@ INSTALLED_APPS = [
|
||||
# Gamer apps
|
||||
'apps.lyric',
|
||||
'apps.epic',
|
||||
'apps.drama',
|
||||
'apps.billboard',
|
||||
# Custom apps
|
||||
'apps.api',
|
||||
'apps.applets',
|
||||
|
||||
@@ -13,6 +13,7 @@ urlpatterns = [
|
||||
path('lyric/', include('apps.lyric.urls')),
|
||||
path('gameboard/', include('apps.gameboard.urls')),
|
||||
path('gameboard/', include('apps.epic.urls')),
|
||||
path('billboard/', include('apps.billboard.urls')),
|
||||
]
|
||||
|
||||
# Please remove the following urlpattern
|
||||
|
||||
113
src/functional_tests/test_billboard.py
Normal file
113
src/functional_tests/test_billboard.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
from .base import FunctionalTest
|
||||
from apps.drama.models import GameEvent, record
|
||||
from apps.epic.models import Room, GateSlot
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class BillboardScrollTest(FunctionalTest):
|
||||
"""
|
||||
FT: game actions in room.html are logged to the drama stream, and the
|
||||
founder can navigate from any page to /billboard/ via the footer
|
||||
fa-scroll icon, select a room, and read the provenance scroll.
|
||||
|
||||
Events are seeded via ORM — the IT suite covers the recording side;
|
||||
here we test the user-visible navigation and prose display.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.founder = User.objects.create(email="founder@scroll.io")
|
||||
self.other = User.objects.create(email="other@scroll.io")
|
||||
self.room = Room.objects.create(name="Blissful Ignorance", owner=self.founder)
|
||||
# Simulate two gate fills — one by founder, one by the other gamer
|
||||
record(
|
||||
self.room, GameEvent.SLOT_FILLED, actor=self.founder,
|
||||
slot_number=1, token_type="coin",
|
||||
token_display="Coin-on-a-String", renewal_days=7,
|
||||
)
|
||||
record(
|
||||
self.room, GameEvent.SLOT_FILLED, actor=self.other,
|
||||
slot_number=2, token_type="Free",
|
||||
token_display="Free Token", renewal_days=7,
|
||||
)
|
||||
# Simulate founder selecting a role
|
||||
record(
|
||||
self.room, GameEvent.ROLE_SELECTED, actor=self.founder,
|
||||
role="PC", slot_number=1, role_display="Player",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 1 — footer icon navigates to billboard, rooms listed #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_footer_scroll_icon_leads_to_billboard_with_rooms(self):
|
||||
# Founder logs in and lands on the dashboard
|
||||
self.create_pre_authenticated_session("founder@scroll.io")
|
||||
self.browser.get(self.live_server_url + "/")
|
||||
|
||||
# Footer contains a scroll icon link pointing to /billboard/
|
||||
scroll_link = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_footer_nav a[href='/billboard/']")
|
||||
)
|
||||
scroll_link.click()
|
||||
|
||||
# Billboard page lists the founder's room
|
||||
self.wait_for(
|
||||
lambda: self.assertIn("/billboard/", self.browser.current_url)
|
||||
)
|
||||
body = self.browser.find_element(By.TAG_NAME, "body")
|
||||
self.assertIn("Blissful Ignorance", body.text)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 2 — scroll page renders human-readable prose #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_scroll_shows_human_readable_event_log(self):
|
||||
self.create_pre_authenticated_session("founder@scroll.io")
|
||||
self.browser.get(self.live_server_url + "/billboard/")
|
||||
|
||||
# Click the room link to reach the scroll
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.LINK_TEXT, "Blissful Ignorance")
|
||||
).click()
|
||||
|
||||
self.wait_for(
|
||||
lambda: self.assertIn("/scroll/", self.browser.current_url)
|
||||
)
|
||||
|
||||
scroll = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
|
||||
)
|
||||
|
||||
# Gate fill events are rendered as prose
|
||||
self.assertIn("deposits a Coin-on-a-String for slot 1 (7 days)", scroll.text)
|
||||
self.assertIn("deposits a Free Token for slot 2 (7 days)", scroll.text)
|
||||
|
||||
# Role selection event is rendered as prose
|
||||
self.assertIn("starts as Player", scroll.text)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 3 — current user's events are right-aligned; others' are left #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_scroll_aligns_own_events_right_and_others_left(self):
|
||||
self.create_pre_authenticated_session("founder@scroll.io")
|
||||
self.browser.get(self.live_server_url + "/billboard/")
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.LINK_TEXT, "Blissful Ignorance")
|
||||
).click()
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
|
||||
)
|
||||
|
||||
mine_events = self.browser.find_elements(By.CSS_SELECTOR, ".drama-event.mine")
|
||||
theirs_events = self.browser.find_elements(By.CSS_SELECTOR, ".drama-event.theirs")
|
||||
|
||||
# Founder has 2 events (slot fill + role select); other gamer has 1
|
||||
self.assertEqual(len(mine_events), 2)
|
||||
self.assertEqual(len(theirs_events), 1)
|
||||
|
||||
# The other gamer's event mentions their display name
|
||||
self.assertIn("other", theirs_events[0].text)
|
||||
18
src/templates/apps/billboard/billboard.html
Normal file
18
src/templates/apps/billboard/billboard.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "core/base.html" %}
|
||||
|
||||
{% block title_text %}Billboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="content dashboard-page">
|
||||
<h2>My Games</h2>
|
||||
<ul class="game-list">
|
||||
{% for room in my_rooms %}
|
||||
<li>
|
||||
<a href="{% url 'billboard:scroll' room.id %}">{{ room.name }}</a>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li><small>No games yet.</small></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
10
src/templates/apps/billboard/room_scroll.html
Normal file
10
src/templates/apps/billboard/room_scroll.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{% extends "core/base.html" %}
|
||||
|
||||
{% block title_text %}{{ room.name }} — Drama Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="content dashboard-page">
|
||||
<h2>{{ room.name }}</h2>
|
||||
{% include "apps/drama/_scroll.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
16
src/templates/apps/drama/_scroll.html
Normal file
16
src/templates/apps/drama/_scroll.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% load lyric_extras %}
|
||||
<section id="id_drama_scroll" class="drama-scroll">
|
||||
{% for event in events %}
|
||||
<div class="drama-event {% if event.actor == viewer %}mine{% else %}theirs{% endif %}">
|
||||
<span class="event-body">
|
||||
<strong>{{ event.actor|display_name }}</strong>
|
||||
{{ event.to_prose }}<br>
|
||||
<time class="event-time" datetime="{{ event.timestamp|date:'c' }}">
|
||||
{{ event.timestamp|date:"N j, g:i a" }}
|
||||
</time>
|
||||
</span>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="event-empty"><small>No events yet.</small></p>
|
||||
{% endfor %}
|
||||
</section>
|
||||
@@ -6,6 +6,9 @@
|
||||
<a href="/gameboard/" class="{% if '/gameboard/' in request.path %}active{% endif %}">
|
||||
<i class="fa-solid fa-chess-board"></i>
|
||||
</a>
|
||||
<a href="/billboard/" class="{% if '/billboard/' in request.path %}active{% endif %}">
|
||||
<i class="fa-solid fa-scroll"></i>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="footer-container">
|
||||
<small>©{% now "Y" %} Dis Co.</small>
|
||||
|
||||
Reference in New Issue
Block a user