rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard

- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-22 22:32:34 -04:00
parent 6d9d3d4f54
commit 473e6bc45a
54 changed files with 1373 additions and 1283 deletions

View File

@@ -1,30 +1,30 @@
from rest_framework import serializers
from apps.dashboard.models import Item, Note
from apps.dashboard.models import Line, Post
from apps.lyric.models import User
class ItemSerializer(serializers.ModelSerializer):
class LineSerializer(serializers.ModelSerializer):
text = serializers.CharField()
def validate_text(self, value):
note = self.context["note"]
if note.item_set.filter(text=value).exists():
post = self.context["post"]
if post.lines.filter(text=value).exists():
raise serializers.ValidationError("duplicate")
return value
class Meta:
model = Item
model = Line
fields = ["id", "text"]
class NoteSerializer(serializers.ModelSerializer):
class PostSerializer(serializers.ModelSerializer):
name = serializers.ReadOnlyField()
url = serializers.CharField(source="get_absolute_url", read_only=True)
items = ItemSerializer(many=True, read_only=True, source="item_set")
lines = LineSerializer(many=True, read_only=True)
class Meta:
model = Note
fields = ["id", "name", "url", "items"]
model = Post
fields = ["id", "name", "url", "lines"]
class UserSerializer(serializers.ModelSerializer):
class Meta:

View File

@@ -1,7 +1,7 @@
from django.test import TestCase
from rest_framework.test import APIClient
from apps.dashboard.models import Item, Note
from apps.dashboard.models import Line, Post
from apps.lyric.models import User
class BaseAPITest(TestCase):
@@ -11,76 +11,76 @@ class BaseAPITest(TestCase):
self.user = User.objects.create_user("test@example.com")
self.client.force_authenticate(user=self.user)
class NoteDetailAPITest(BaseAPITest):
def test_returns_note_with_items(self):
note = Note.objects.create(owner=self.user)
Item.objects.create(text="item 1", note=note)
Item.objects.create(text="item 2", note=note)
class PostDetailAPITest(BaseAPITest):
def test_returns_post_with_lines(self):
post = Post.objects.create(owner=self.user)
Line.objects.create(text="line 1", post=post)
Line.objects.create(text="line 2", post=post)
response = self.client.get(f"/api/notes/{note.id}/")
response = self.client.get(f"/api/posts/{post.id}/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["id"], str(note.id))
self.assertEqual(len(response.data["items"]), 2)
self.assertEqual(response.data["id"], str(post.id))
self.assertEqual(len(response.data["lines"]), 2)
class NoteItemsAPITest(BaseAPITest):
def test_can_add_item_to_note(self):
note = Note.objects.create(owner=self.user)
class PostLinesAPITest(BaseAPITest):
def test_can_add_line_to_post(self):
post = Post.objects.create(owner=self.user)
response = self.client.post(
f"/api/notes/{note.id}/items/",
{"text": "a new item"},
f"/api/posts/{post.id}/lines/",
{"text": "a new line"},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(Item.objects.count(), 1)
self.assertEqual(Item.objects.first().text, "a new item")
self.assertEqual(Line.objects.count(), 1)
self.assertEqual(Line.objects.first().text, "a new line")
def test_cannot_add_empty_item_to_note(self):
note = Note.objects.create(owner=self.user)
def test_cannot_add_empty_line_to_post(self):
post = Post.objects.create(owner=self.user)
response = self.client.post(
f"/api/notes/{note.id}/items/",
f"/api/posts/{post.id}/lines/",
{"text": ""},
)
self.assertEqual(response.status_code, 400)
self.assertEqual(Item.objects.count(), 0)
self.assertEqual(Line.objects.count(), 0)
def test_cannot_add_duplicate_item_to_note(self):
note = Note.objects.create(owner=self.user)
Item.objects.create(text="note item", note=note)
def test_cannot_add_duplicate_line_to_post(self):
post = Post.objects.create(owner=self.user)
Line.objects.create(text="post line", post=post)
duplicate_response = self.client.post(
f"/api/notes/{note.id}/items/",
{"text": "note item"},
f"/api/posts/{post.id}/lines/",
{"text": "post line"},
)
self.assertEqual(duplicate_response.status_code, 400)
self.assertEqual(Item.objects.count(), 1)
self.assertEqual(Line.objects.count(), 1)
class NotesAPITest(BaseAPITest):
def test_get_returns_only_users_notes(self):
note1 = Note.objects.create(owner=self.user)
Item.objects.create(text="item 1", note=note1)
class PostsAPITest(BaseAPITest):
def test_get_returns_only_users_posts(self):
post1 = Post.objects.create(owner=self.user)
Line.objects.create(text="line 1", post=post1)
other_user = User.objects.create_user("other@example.com")
Note.objects.create(owner=other_user)
Post.objects.create(owner=other_user)
response = self.client.get("/api/notes/")
response = self.client.get("/api/posts/")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["id"], str(note1.id))
self.assertEqual(response.data[0]["id"], str(post1.id))
def test_post_creates_note_with_item(self):
def test_post_creates_post_with_line(self):
response = self.client.post(
"/api/notes/",
{"text": "first item"},
"/api/posts/",
{"text": "first line"},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(Note.objects.count(), 1)
self.assertEqual(Note.objects.first().owner, self.user)
self.assertEqual(Item.objects.first().text, "first item")
self.assertEqual(Post.objects.count(), 1)
self.assertEqual(Post.objects.first().owner, self.user)
self.assertEqual(Line.objects.first().text, "first line")
class UserSearchAPITest(BaseAPITest):
def test_returns_users_matching_username(self):
@@ -98,7 +98,7 @@ class UserSearchAPITest(BaseAPITest):
def test_non_searchable_users_are_excluded(self):
alice = User.objects.create_user("alice@example.com")
alice.username = "princessAli"
alice.save() # searchable defaults to False
alice.save() # searchable defaults to False
response = self.client.get("/api/users/?q=prin")

View File

@@ -1,19 +1,19 @@
from django.test import SimpleTestCase
from apps.api.serializers import ItemSerializer, NoteSerializer
from apps.api.serializers import LineSerializer, PostSerializer
class ItemSerializerTest(SimpleTestCase):
class LineSerializerTest(SimpleTestCase):
def test_fields(self):
serializer = ItemSerializer()
serializer = LineSerializer()
self.assertEqual(
set(serializer.fields.keys()),
{"id", "text"},
)
class NoteSerializerTest(SimpleTestCase):
class PostSerializerTest(SimpleTestCase):
def test_fields(self):
serializer = NoteSerializer()
serializer = PostSerializer()
self.assertEqual(
set(serializer.fields.keys()),
{"id", "name", "url", "items"},
{"id", "name", "url", "lines"},
)

View File

@@ -4,8 +4,8 @@ from . import views
urlpatterns = [
path('notes/', views.NotesAPI.as_view(), name='api_notes'),
path('notes/<uuid:note_id>/', views.NoteDetailAPI.as_view(), name='api_note_detail'),
path('notes/<uuid:note_id>/items/', views.NoteItemsAPI.as_view(), name='api_note_items'),
path('posts/', views.PostsAPI.as_view(), name='api_posts'),
path('posts/<uuid:post_id>/', views.PostDetailAPI.as_view(), name='api_post_detail'),
path('posts/<uuid:post_id>/lines/', views.PostLinesAPI.as_view(), name='api_post_lines'),
path('users/', views.UserSearchAPI.as_view(), name='api_users'),
]

View File

@@ -2,36 +2,36 @@ from django.shortcuts import get_object_or_404
from rest_framework.views import APIView
from rest_framework.response import Response
from apps.api.serializers import ItemSerializer, NoteSerializer, UserSerializer
from apps.dashboard.models import Item, Note
from apps.api.serializers import LineSerializer, PostSerializer, UserSerializer
from apps.dashboard.models import Line, Post
from apps.lyric.models import User
class NoteDetailAPI(APIView):
def get(self, request, note_id):
note = get_object_or_404(Note, id=note_id)
serializer = NoteSerializer(note)
class PostDetailAPI(APIView):
def get(self, request, post_id):
post = get_object_or_404(Post, id=post_id)
serializer = PostSerializer(post)
return Response(serializer.data)
class NoteItemsAPI(APIView):
def post(self, request, note_id):
note = get_object_or_404(Note, id=note_id)
serializer = ItemSerializer(data=request.data, context={"note": note})
class PostLinesAPI(APIView):
def post(self, request, post_id):
post = get_object_or_404(Post, id=post_id)
serializer = LineSerializer(data=request.data, context={"post": post})
if serializer.is_valid():
serializer.save(note=note)
serializer.save(post=post)
return Response(serializer.data, status=201)
return Response(serializer.errors, status=400)
class NotesAPI(APIView):
class PostsAPI(APIView):
def get(self, request):
notes = Note.objects.filter(owner=request.user)
serializer = NoteSerializer(notes, many=True)
posts = Post.objects.filter(owner=request.user)
serializer = PostSerializer(posts, many=True)
return Response(serializer.data)
def post(self, request):
note = Note.objects.create(owner=request.user)
item = Item.objects.create(text=request.data.get("text", ""), note=note)
serializer = NoteSerializer(note)
post = Post.objects.create(owner=request.user)
line = Line.objects.create(text=request.data.get("text", ""), post=post)
serializer = PostSerializer(post)
return Response(serializer.data, status=201)
class UserSearchAPI(APIView):

View File

@@ -0,0 +1,24 @@
from django.db import migrations
def rename_note_applets_to_post(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
Applet.objects.filter(slug='new-note').update(slug='new-post', name='New Post', context='billboard')
Applet.objects.filter(slug='my-notes').update(slug='my-posts', name='My Posts', context='billboard')
def reverse_rename_note_applets_to_post(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
Applet.objects.filter(slug='new-post').update(slug='new-note', name='New Note', context='dashboard')
Applet.objects.filter(slug='my-posts').update(slug='my-notes', name='My Notes', context='dashboard')
class Migration(migrations.Migration):
dependencies = [
('applets', '0010_recognition_applet'),
]
operations = [
migrations.RunPython(rename_note_applets_to_post, reverse_rename_note_applets_to_post),
]

View File

@@ -0,0 +1,31 @@
from django.db import migrations
def rename_recognition_applet_to_notes(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
Applet.objects.filter(slug='billboard-recognition').update(
slug='billboard-notes',
name='My Notes',
)
def reverse_rename_recognition_applet_to_notes(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
Applet.objects.filter(slug='billboard-notes').update(
slug='billboard-recognition',
name='Recognition',
)
class Migration(migrations.Migration):
dependencies = [
('applets', '0011_rename_note_applets_to_post'),
]
operations = [
migrations.RunPython(
rename_recognition_applet_to_notes,
reverse_rename_recognition_applet_to_notes,
),
]

View File

@@ -8,7 +8,7 @@
// ── helpers ──────────────────────────────────────────────────────────────
function _activeModal() {
return _activeItem && _activeItem.querySelector('.recog-palette-modal');
return _activeItem && _activeItem.querySelector('.note-palette-modal');
}
function _paletteClass(el) {
@@ -26,14 +26,14 @@
// Clone from <template> if the modal is not in the DOM yet.
var existing = _activeModal();
if (!existing) {
var tpl = _activeItem.querySelector('.recog-palette-modal-tpl');
var tpl = _activeItem.querySelector('.note-palette-modal-tpl');
if (!tpl) return;
var clone = tpl.content.firstElementChild.cloneNode(true);
_activeItem.appendChild(clone);
_wireModal();
}
_state = 'open';
var confirmEl = _activeModal().querySelector('.recog-palette-confirm');
var confirmEl = _activeModal().querySelector('.note-palette-confirm');
if (confirmEl) confirmEl.hidden = true;
}
@@ -49,7 +49,7 @@
if (!modal) return;
// Swatch body → preview (remove modal from DOM)
modal.querySelectorAll('.recog-swatch-body').forEach(function (body) {
modal.querySelectorAll('.note-swatch-body').forEach(function (body) {
body.addEventListener('click', function (e) {
e.stopPropagation();
_selectedPalette = _paletteClass(body.parentElement);
@@ -60,17 +60,17 @@
// OK button (not inside confirm submenu) → show confirm submenu
modal.querySelectorAll('.btn.btn-confirm').forEach(function (btn) {
if (btn.closest('.recog-palette-confirm')) return;
if (btn.closest('.note-palette-confirm')) return;
btn.addEventListener('click', function (e) {
e.stopPropagation();
_selectedPalette = _paletteClass(btn.parentElement);
var confirmEl = modal.querySelector('.recog-palette-confirm');
var confirmEl = modal.querySelector('.note-palette-confirm');
if (confirmEl) confirmEl.hidden = false;
});
});
// Confirm YES → POST and update DOM
modal.querySelectorAll('.recog-palette-confirm .btn.btn-confirm').forEach(function (btn) {
modal.querySelectorAll('.note-palette-confirm .btn.btn-confirm').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
_doSetPalette();
@@ -78,10 +78,10 @@
});
// Confirm NVM → hide confirm submenu
modal.querySelectorAll('.recog-palette-confirm .btn.btn-cancel').forEach(function (btn) {
modal.querySelectorAll('.note-palette-confirm .btn.btn-cancel').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
btn.closest('.recog-palette-confirm').hidden = true;
btn.closest('.note-palette-confirm').hidden = true;
});
});
@@ -106,10 +106,10 @@
.then(function (r) { return r.json(); })
.then(function () {
_closeModal();
var imageBox = _activeItem.querySelector('.recog-item__image-box');
var imageBox = _activeItem.querySelector('.note-item__image-box');
if (imageBox) {
var swatch = document.createElement('div');
swatch.className = 'recog-item__palette ' + palette;
swatch.className = 'note-item__palette ' + palette;
imageBox.parentNode.replaceChild(swatch, imageBox);
}
});
@@ -119,10 +119,10 @@
function _init() {
// Image-box click → open modal
document.querySelectorAll('.recog-item__image-box').forEach(function (box) {
document.querySelectorAll('.note-item__image-box').forEach(function (box) {
box.addEventListener('click', function (e) {
e.stopPropagation();
_activeItem = box.closest('.recog-item');
_activeItem = box.closest('.note-item');
_openModal();
});
});

View File

@@ -5,7 +5,7 @@ from django.urls import reverse
from django.utils import timezone
from apps.applets.models import Applet
from apps.drama.models import GameEvent, Recognition, ScrollPosition, record
from apps.drama.models import GameEvent, Note, ScrollPosition, record
from apps.epic.models import Room
from apps.lyric.models import User
@@ -164,65 +164,65 @@ class BillscrollViewTest(TestCase):
self.assertContains(response, 'class="drama-event-time"')
class RecognitionPageViewTest(TestCase):
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/recognition/")
response = self.client.get("/billboard/my-notes/")
self.assertEqual(response.status_code, 302)
def test_returns_200(self):
response = self.client.get("/billboard/recognition/")
response = self.client.get("/billboard/my-notes/")
self.assertEqual(response.status_code, 200)
def test_uses_recognition_template(self):
response = self.client.get("/billboard/recognition/")
self.assertTemplateUsed(response, "apps/billboard/recognition.html")
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_recognitions_in_context(self):
recog = Recognition.objects.create(
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/recognition/")
self.assertIn(recog, response.context["recognitions"])
response = self.client.get("/billboard/my-notes/")
self.assertIn(recog, response.context["notes"])
def test_excludes_other_users_recognitions(self):
def test_excludes_other_users_notes(self):
other = User.objects.create(email="other@test.io")
Recognition.objects.create(
Note.objects.create(
user=other, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/recognition/")
self.assertEqual(list(response.context["recognitions"]), [])
response = self.client.get("/billboard/my-notes/")
self.assertEqual(list(response.context["notes"]), [])
def test_renders_recog_list_and_items(self):
Recognition.objects.create(
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/recognition/")
self.assertContains(response, 'class="recog-list"')
self.assertContains(response, 'class="recog-item"')
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):
Recognition.objects.create(
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/recognition/")
self.assertContains(response, 'class="recog-item__title"')
self.assertContains(response, 'class="recog-item__description"')
self.assertContains(response, 'class="recog-item__image-box"')
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"')
class RecognitionSetPaletteViewTest(TestCase):
class NoteSetPaletteViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="setpal@test.io")
self.client.force_login(self.user)
self.recognition = Recognition.objects.create(
self.note = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
self.url = "/billboard/recognition/stargazer/set-palette"
self.url = "/billboard/note/stargazer/set-palette"
def test_requires_login(self):
self.client.logout()
@@ -233,14 +233,14 @@ class RecognitionSetPaletteViewTest(TestCase):
)
self.assertEqual(response.status_code, 302)
def test_sets_palette_on_recognition(self):
def test_sets_palette_on_note(self):
self.client.post(
self.url,
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.recognition.refresh_from_db()
self.assertEqual(self.recognition.palette, "palette-bardo")
self.note.refresh_from_db()
self.assertEqual(self.note.palette, "palette-bardo")
def test_returns_200_with_ok(self):
response = self.client.post(
@@ -253,7 +253,7 @@ class RecognitionSetPaletteViewTest(TestCase):
def test_returns_404_for_slug_user_does_not_own(self):
response = self.client.post(
"/billboard/recognition/schizo/set-palette",
"/billboard/note/schizo/set-palette",
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)

View File

@@ -7,8 +7,8 @@ app_name = "billboard"
urlpatterns = [
path("", views.billboard, name="billboard"),
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
path("recognition/", views.recognition_page, name="recognition"),
path("recognition/<slug:slug>/set-palette", views.recognition_set_palette, name="recognition_set_palette"),
path("my-notes/", views.my_notes, name="my_notes"),
path("note/<slug:slug>/set-palette", views.note_set_palette, name="note_set_palette"),
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"),
path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"),
]

View File

@@ -6,11 +6,24 @@ from django.http import JsonResponse
from django.shortcuts import redirect, render
from apps.applets.utils import applet_context, apply_applet_toggle
from apps.drama.models import GameEvent, Recognition, ScrollPosition
from apps.dashboard.forms import LineForm
from apps.dashboard.models import Post
from apps.drama.models import GameEvent, Note, ScrollPosition
from apps.epic.models import Room
from apps.epic.utils import rooms_for_user
def _recent_posts(user, limit=3):
return (
Post
.objects
.filter(Q(owner=user) | Q(shared_with=user))
.annotate(last_line=Max('lines__id'))
.order_by('-last_line')
.distinct()[:limit]
)
@login_required(login_url="/")
def billboard(request):
my_rooms = rooms_for_user(request.user).order_by("-created_at")
@@ -42,6 +55,8 @@ def billboard(request):
"recent_events": recent_events,
"viewer": request.user,
"applets": applet_context(request.user, "billboard"),
"form": LineForm(),
"recent_posts": _recent_posts(request.user),
"page_class": "page-billboard",
})
@@ -53,6 +68,8 @@ def toggle_billboard_applets(request):
if request.headers.get("HX-Request"):
return render(request, "apps/billboard/_partials/_applets.html", {
"applets": applet_context(request.user, "billboard"),
"form": LineForm(),
"recent_posts": _recent_posts(request.user),
})
return redirect("billboard:billboard")
@@ -71,7 +88,7 @@ def room_scroll(request, room_id):
})
_RECOGNITION_META = {
_NOTE_META = {
"stargazer": {
"title": "Stargazer",
"description": "You saved your first personal sky chart.",
@@ -91,35 +108,35 @@ _RECOGNITION_META = {
@login_required(login_url="/")
def recognition_set_palette(request, slug):
def note_set_palette(request, slug):
from django.http import Http404
try:
recognition = Recognition.objects.get(user=request.user, slug=slug)
except Recognition.DoesNotExist:
note = Note.objects.get(user=request.user, slug=slug)
except Note.DoesNotExist:
raise Http404
if request.method == "POST":
body = json.loads(request.body)
recognition.palette = body.get("palette", "")
recognition.save(update_fields=["palette"])
note.palette = body.get("palette", "")
note.save(update_fields=["palette"])
return JsonResponse({"ok": True})
@login_required(login_url="/")
def recognition_page(request):
qs = Recognition.objects.filter(user=request.user)
recognitions = [
def my_notes(request):
qs = Note.objects.filter(user=request.user)
note_items = [
{
"obj": r,
"title": _RECOGNITION_META.get(r.slug, {}).get("title", r.slug),
"description": _RECOGNITION_META.get(r.slug, {}).get("description", ""),
"palette_options": _RECOGNITION_META.get(r.slug, {}).get("palette_options", []),
"obj": n,
"title": _NOTE_META.get(n.slug, {}).get("title", n.slug),
"description": _NOTE_META.get(n.slug, {}).get("description", ""),
"palette_options": _NOTE_META.get(n.slug, {}).get("palette_options", []),
}
for r in qs
for n in qs
]
return render(request, "apps/billboard/recognition.html", {
"recognitions": qs,
"recognition_items": recognitions,
"page_class": "page-recognition",
return render(request, "apps/billboard/my_notes.html", {
"notes": qs,
"note_items": note_items,
"page_class": "page-notes",
})

View File

@@ -1,32 +1,32 @@
from django import forms
from django.core.exceptions import ValidationError
from .models import Item
from .models import Line
DUPLICATE_ITEM_ERROR = "You've already logged this to your note"
EMPTY_ITEM_ERROR = "You can't have an empty note item"
DUPLICATE_LINE_ERROR = "You've already logged this to your post"
EMPTY_LINE_ERROR = "You can't have an empty post line"
class ItemForm(forms.Form):
class LineForm(forms.Form):
text = forms.CharField(
error_messages = {"required": EMPTY_ITEM_ERROR},
error_messages = {"required": EMPTY_LINE_ERROR},
required=True,
)
def save(self, for_note):
return Item.objects.create(
note=for_note,
def save(self, for_post):
return Line.objects.create(
post=for_post,
text=self.cleaned_data["text"],
)
class ExistingNoteItemForm(ItemForm):
def __init__(self, for_note, *args, **kwargs):
class ExistingPostLineForm(LineForm):
def __init__(self, for_post, *args, **kwargs):
super().__init__(*args, **kwargs)
self._for_note = for_note
self._for_post = for_post
def clean_text(self):
text = self.cleaned_data["text"]
if self._for_note.item_set.filter(text=text).exists():
raise forms.ValidationError(DUPLICATE_ITEM_ERROR)
if self._for_post.lines.filter(text=text).exists():
raise forms.ValidationError(DUPLICATE_LINE_ERROR)
return text
def save(self):
return super().save(for_note=self._for_note)
return super().save(for_post=self._for_post)

View File

@@ -0,0 +1,18 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0003_alter_note_owner_alter_note_shared_with'),
]
operations = [
migrations.RenameModel('Note', 'Post'),
migrations.RenameModel('Item', 'Line'),
migrations.RenameField('Line', 'note', 'post'),
migrations.AlterUniqueTogether(
name='line',
unique_together={('post', 'text')},
),
]

View File

@@ -4,11 +4,11 @@ from django.db import models
from django.urls import reverse
class Note(models.Model):
class Post(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(
"lyric.User",
related_name="notes",
related_name="posts",
blank=True,
null=True,
on_delete=models.CASCADE,
@@ -16,24 +16,24 @@ class Note(models.Model):
shared_with = models.ManyToManyField(
"lyric.User",
related_name="shared_notes",
related_name="shared_posts",
blank=True,
)
@property
def name(self):
return self.item_set.first().text
return self.lines.first().text
def get_absolute_url(self):
return reverse("view_note", args=[self.id])
return reverse("view_post", args=[self.id])
class Item(models.Model):
class Line(models.Model):
text = models.TextField(default="")
note = models.ForeignKey(Note, default=None, on_delete=models.CASCADE)
post = models.ForeignKey(Post, default=None, on_delete=models.CASCADE, related_name="lines")
class Meta:
ordering = ("id",)
unique_together = ("note", "text")
unique_together = ("post", "text")
def __str__(self):
return self.text

View File

@@ -0,0 +1,49 @@
const Note = (() => {
'use strict';
function showBanner(note) {
if (!note) return;
const earned = new Date(note.earned_at);
const dateStr = earned.toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
});
const banner = document.createElement('div');
banner.className = 'note-banner';
banner.innerHTML =
'<div class="note-banner__body">' +
'<p class="note-banner__title">' + _esc(note.title) + '</p>' +
'<p class="note-banner__description">' + _esc(note.description) + '</p>' +
'<time class="note-banner__timestamp" datetime="' + _esc(note.earned_at) + '">' +
dateStr +
'</time>' +
'</div>' +
'<div class="note-banner__image"></div>' +
'<button type="button" class="btn btn-danger note-banner__nvm">NVM</button>' +
'<a href="/billboard/my-notes/" class="btn btn-caution note-banner__fyi">FYI</a>';
banner.querySelector('.note-banner__nvm').addEventListener('click', function () {
banner.remove();
});
var h2 = document.querySelector('h2');
if (h2 && h2.parentNode) {
h2.parentNode.insertBefore(banner, h2.nextSibling);
} else {
document.body.insertBefore(banner, document.body.firstChild);
}
}
function handleSaveResponse(data) {
showBanner(data && data.note);
}
function _esc(str) {
var d = document.createElement('div');
d.textContent = str || '';
return d.innerHTML;
}
return { showBanner: showBanner, handleSaveResponse: handleSaveResponse };
})();

View File

@@ -1,49 +0,0 @@
const Recognition = (() => {
'use strict';
function showBanner(recognition) {
if (!recognition) return;
const earned = new Date(recognition.earned_at);
const dateStr = earned.toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
});
const banner = document.createElement('div');
banner.className = 'recog-banner';
banner.innerHTML =
'<div class="recog-banner__body">' +
'<p class="recog-banner__title">' + _esc(recognition.title) + '</p>' +
'<p class="recog-banner__description">' + _esc(recognition.description) + '</p>' +
'<time class="recog-banner__timestamp" datetime="' + _esc(recognition.earned_at) + '">' +
dateStr +
'</time>' +
'</div>' +
'<div class="recog-banner__image"></div>' +
'<button type="button" class="btn btn-danger recog-banner__nvm">NVM</button>' +
'<a href="/billboard/recognition/" class="btn btn-caution recog-banner__fyi">FYI</a>';
banner.querySelector('.recog-banner__nvm').addEventListener('click', function () {
banner.remove();
});
var h2 = document.querySelector('h2');
if (h2 && h2.parentNode) {
h2.parentNode.insertBefore(banner, h2.nextSibling);
} else {
document.body.insertBefore(banner, document.body.firstChild);
}
}
function handleSaveResponse(data) {
showBanner(data && data.recognition);
}
function _esc(str) {
var d = document.createElement('div');
d.textContent = str || '';
return d.innerHTML;
}
return { showBanner: showBanner, handleSaveResponse: handleSaveResponse };
})();

View File

@@ -1,41 +1,41 @@
from django.test import TestCase
from apps.dashboard.forms import (
DUPLICATE_ITEM_ERROR,
EMPTY_ITEM_ERROR,
ExistingNoteItemForm,
ItemForm,
DUPLICATE_LINE_ERROR,
EMPTY_LINE_ERROR,
ExistingPostLineForm,
LineForm,
)
from apps.dashboard.models import Item, Note
from apps.dashboard.models import Line, Post
class ItemFormTest(TestCase):
def test_form_save_handles_saving_to_a_note(self):
mynote = Note.objects.create()
form = ItemForm(data={"text": "do re mi"})
class LineFormTest(TestCase):
def test_form_save_handles_saving_to_a_post(self):
mypost = Post.objects.create()
form = LineForm(data={"text": "do re mi"})
self.assertTrue(form.is_valid())
new_item = form.save(for_note=mynote)
self.assertEqual(new_item, Item.objects.get())
self.assertEqual(new_item.text, "do re mi")
self.assertEqual(new_item.note, mynote)
new_line = form.save(for_post=mypost)
self.assertEqual(new_line, Line.objects.get())
self.assertEqual(new_line.text, "do re mi")
self.assertEqual(new_line.post, mypost)
class ExistingNoteItemFormTest(TestCase):
def test_form_validation_for_blank_items(self):
note = Note.objects.create()
form = ExistingNoteItemForm(for_note=note, data={"text": ""})
class ExistingPostLineFormTest(TestCase):
def test_form_validation_for_blank_lines(self):
post = Post.objects.create()
form = ExistingPostLineForm(for_post=post, data={"text": ""})
self.assertFalse(form.is_valid())
self.assertEqual(form.errors["text"], [EMPTY_ITEM_ERROR])
self.assertEqual(form.errors["text"], [EMPTY_LINE_ERROR])
def test_form_validation_for_duplicate_items(self):
note = Note.objects.create()
Item.objects.create(note=note, text="twins, basil")
form = ExistingNoteItemForm(for_note=note, data={"text": "twins, basil"})
def test_form_validation_for_duplicate_lines(self):
post = Post.objects.create()
Line.objects.create(post=post, text="twins, basil")
form = ExistingPostLineForm(for_post=post, data={"text": "twins, basil"})
self.assertFalse(form.is_valid())
self.assertEqual(form.errors["text"], [DUPLICATE_ITEM_ERROR])
self.assertEqual(form.errors["text"], [DUPLICATE_LINE_ERROR])
def test_form_save(self):
mynote = Note.objects.create()
form = ExistingNoteItemForm(for_note=mynote, data={"text": "howdy"})
mypost = Post.objects.create()
form = ExistingPostLineForm(for_post=mypost, data={"text": "howdy"})
self.assertTrue(form.is_valid())
new_item = form.save()
self.assertEqual(new_item, Item.objects.get())
new_line = form.save()
self.assertEqual(new_line, Line.objects.get())

View File

@@ -2,69 +2,69 @@ from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError
from django.test import TestCase
from apps.dashboard.models import Item, Note
from apps.dashboard.models import Line, Post
from apps.lyric.models import User
class ItemModelTest(TestCase):
def test_item_is_related_to_note(self):
mynote = Note.objects.create()
item = Item()
item.note = mynote
item.save()
self.assertIn(item, mynote.item_set.all())
class LineModelTest(TestCase):
def test_line_is_related_to_post(self):
mypost = Post.objects.create()
line = Line()
line.post = mypost
line.save()
self.assertIn(line, mypost.lines.all())
def test_cannot_save_null_note_items(self):
mynote = Note.objects.create()
item = Item(note=mynote, text=None)
def test_cannot_save_null_post_lines(self):
mypost = Post.objects.create()
line = Line(post=mypost, text=None)
with self.assertRaises(IntegrityError):
item.save()
line.save()
def test_cannot_save_empty_note_items(self):
mynote = Note.objects.create()
item = Item(note=mynote, text="")
def test_cannot_save_empty_post_lines(self):
mypost = Post.objects.create()
line = Line(post=mypost, text="")
with self.assertRaises(ValidationError):
item.full_clean()
line.full_clean()
def test_duplicate_items_are_invalid(self):
mynote = Note.objects.create()
Item.objects.create(note=mynote, text="jklol")
def test_duplicate_lines_are_invalid(self):
mypost = Post.objects.create()
Line.objects.create(post=mypost, text="jklol")
with self.assertRaises(ValidationError):
item = Item(note=mynote, text="jklol")
item.full_clean()
line = Line(post=mypost, text="jklol")
line.full_clean()
def test_still_can_save_same_item_to_different_notes(self):
note1 = Note.objects.create()
note2 = Note.objects.create()
Item.objects.create(note=note1, text="nojk")
item = Item(note=note2, text="nojk")
item.full_clean() # should not raise
def test_still_can_save_same_line_to_different_posts(self):
post1 = Post.objects.create()
post2 = Post.objects.create()
Line.objects.create(post=post1, text="nojk")
line = Line(post=post2, text="nojk")
line.full_clean() # should not raise
class NoteModelTest(TestCase):
class PostModelTest(TestCase):
def test_get_absolute_url(self):
mynote = Note.objects.create()
self.assertEqual(mynote.get_absolute_url(), f"/dashboard/note/{mynote.id}/")
mypost = Post.objects.create()
self.assertEqual(mypost.get_absolute_url(), f"/dashboard/post/{mypost.id}/")
def test_note_items_order(self):
note1 = Note.objects.create()
item1 = Item.objects.create(note=note1, text="i1")
item2 = Item.objects.create(note=note1, text="item 2")
item3 = Item.objects.create(note=note1, text="3")
def test_post_lines_order(self):
post1 = Post.objects.create()
line1 = Line.objects.create(post=post1, text="i1")
line2 = Line.objects.create(post=post1, text="line 2")
line3 = Line.objects.create(post=post1, text="3")
self.assertEqual(
list(note1.item_set.all()),
[item1, item2, item3],
list(post1.lines.all()),
[line1, line2, line3],
)
def test_notes_can_have_owners(self):
def test_posts_can_have_owners(self):
user = User.objects.create(email="a@b.cde")
mynote = Note.objects.create(owner=user)
self.assertIn(mynote, user.notes.all())
mypost = Post.objects.create(owner=user)
self.assertIn(mypost, user.posts.all())
def test_note_owner_is_optional(self):
Note.objects.create()
def test_post_owner_is_optional(self):
Post.objects.create()
def test_note_name_is_first_item_text(self):
note = Note.objects.create()
Item.objects.create(note=note, text="first item")
Item.objects.create(note=note, text="second item")
self.assertEqual(note.name, "first item")
def test_post_name_is_first_line_text(self):
post = Post.objects.create()
Line.objects.create(post=post, text="first line")
Line.objects.create(post=post, text="second line")
self.assertEqual(post.name, "first line")

View File

@@ -3,7 +3,7 @@
sky_view — GET /dashboard/sky/ → renders sky template
sky_preview — GET /dashboard/sky/preview → proxies to PySwiss (no DB write)
sky_save — POST /dashboard/sky/save → saves natal data to User model;
grants Stargazer Recognition on first save with real chart_data
grants Stargazer Note on first save with real chart_data
"""
import json
@@ -12,7 +12,7 @@ from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.urls import reverse
from apps.drama.models import Recognition
from apps.drama.models import Note
from apps.lyric.models import User
@@ -233,8 +233,8 @@ _REAL_CHART = {
}
class SkySaveRecognitionTest(TestCase):
"""sky_save grants the Stargazer Recognition on the first save with real chart_data."""
class SkySaveNoteTest(TestCase):
"""sky_save grants the Stargazer Note on the first save with real chart_data."""
def setUp(self):
self.user = User.objects.create(email="star@test.io")
@@ -256,35 +256,35 @@ class SkySaveRecognitionTest(TestCase):
content_type="application/json",
)
def test_first_save_with_chart_data_returns_stargazer_recognition(self):
def test_first_save_with_chart_data_returns_stargazer_note(self):
data = self._post().json()
self.assertIn("recognition", data)
recog = data["recognition"]
self.assertIn("note", data)
recog = data["note"]
self.assertEqual(recog["slug"], "stargazer")
self.assertIn("title", recog)
self.assertIn("description", recog)
self.assertIn("earned_at", recog)
def test_first_save_creates_recognition_in_db(self):
def test_first_save_creates_note_in_db(self):
self._post()
self.assertEqual(Recognition.objects.filter(user=self.user, slug="stargazer").count(), 1)
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 1)
def test_second_save_returns_null_recognition(self):
def test_second_save_returns_null_note(self):
self._post()
data = self._post().json()
self.assertIsNone(data["recognition"])
self.assertIsNone(data["note"])
def test_second_save_does_not_create_duplicate_recognition(self):
def test_second_save_does_not_create_duplicate_note(self):
self._post()
self._post()
self.assertEqual(Recognition.objects.filter(user=self.user, slug="stargazer").count(), 1)
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 1)
def test_save_with_empty_chart_data_does_not_grant_recognition(self):
def test_save_with_empty_chart_data_does_not_grant_note(self):
data = self._post(chart_data={}).json()
self.assertIsNone(data["recognition"])
self.assertEqual(Recognition.objects.filter(user=self.user, slug="stargazer").count(), 0)
self.assertIsNone(data["note"])
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0)
def test_save_with_null_chart_data_does_not_grant_recognition(self):
def test_save_with_null_chart_data_does_not_grant_note(self):
data = self._post(chart_data=None).json()
self.assertIsNone(data["recognition"])
self.assertEqual(Recognition.objects.filter(user=self.user, slug="stargazer").count(), 0)
self.assertIsNone(data["note"])
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0)

View File

@@ -9,11 +9,11 @@ from django.utils import html, timezone
from apps.applets.models import Applet, UserApplet
from apps.dashboard.forms import (
DUPLICATE_ITEM_ERROR,
EMPTY_ITEM_ERROR,
DUPLICATE_LINE_ERROR,
EMPTY_LINE_ERROR,
)
from apps.dashboard.models import Item, Note
from apps.drama.models import Recognition
from apps.dashboard.models import Line, Post
from apps.drama.models import Note
from apps.lyric.models import User
@@ -21,64 +21,59 @@ class HomePageTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(slug="new-note", defaults={"name": "New Note"})
def test_uses_home_template(self):
response = self.client.get('/')
self.assertTemplateUsed(response, 'apps/dashboard/home.html')
def test_renders_input_form(self):
response = self.client.get('/')
parsed = lxml.html.fromstring(response.content)
forms = parsed.cssselect('form[method=POST]')
self.assertIn("/dashboard/new_note", [form.get("action") for form in forms])
[form] = [form for form in forms if form.get("action") == "/dashboard/new_note"]
inputs = form.cssselect("input")
self.assertIn("text", [input.get("name") for input in inputs])
class NewNoteTest(TestCase):
@override_settings(COMPRESS_ENABLED=False)
class NewPostTest(TestCase):
def setUp(self):
user = User.objects.create(email="disco@test.io")
self.client.force_login(user)
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(
slug="new-post",
defaults={"name": "New Post", "context": "billboard", "grid_cols": 9, "grid_rows": 3},
)
def test_can_save_a_POST_request(self):
self.client.post("/dashboard/new_note", data={"text": "A new note item"})
self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.get()
self.assertEqual(new_item.text, "A new note item")
self.client.post("/dashboard/new_post", data={"text": "A new post line"})
self.assertEqual(Line.objects.count(), 1)
new_line = Line.objects.get()
self.assertEqual(new_line.text, "A new post line")
def test_redirects_after_POST(self):
response = self.client.post("/dashboard/new_note", data={"text": "A new note item"})
new_note = Note.objects.get()
self.assertRedirects(response, f"/dashboard/note/{new_note.id}/")
response = self.client.post("/dashboard/new_post", data={"text": "A new post line"})
new_post = Post.objects.get()
self.assertRedirects(response, f"/dashboard/post/{new_post.id}/")
# Post invalid input helper
def post_invalid_input(self):
return self.client.post("/dashboard/new_note", data={"text": ""})
return self.client.post("/dashboard/new_post", data={"text": ""})
def test_for_invalid_input_nothing_saved_to_db(self):
self.post_invalid_input()
self.assertEqual(Item.objects.count(), 0)
self.assertEqual(Line.objects.count(), 0)
def test_for_invalid_input_renders_home_template(self):
def test_for_invalid_input_renders_billboard_template(self):
response = self.post_invalid_input()
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/dashboard/home.html")
self.assertTemplateUsed(response, "apps/billboard/billboard.html")
def test_for_invalid_input_shows_error_on_page(self):
response = self.post_invalid_input()
self.assertContains(response, html.escape(EMPTY_ITEM_ERROR))
self.assertContains(response, html.escape(EMPTY_LINE_ERROR))
@override_settings(COMPRESS_ENABLED=False)
class NoteViewTest(TestCase):
def test_uses_note_template(self):
mynote = Note.objects.create()
response = self.client.get(f"/dashboard/note/{mynote.id}/")
self.assertTemplateUsed(response, "apps/dashboard/note.html")
class PostViewTest(TestCase):
def test_uses_post_template(self):
mypost = Post.objects.create()
response = self.client.get(f"/dashboard/post/{mypost.id}/")
self.assertTemplateUsed(response, "apps/dashboard/post.html")
def test_renders_input_form(self):
mynote = Note.objects.create()
url = f"/dashboard/note/{mynote.id}/"
mypost = Post.objects.create()
url = f"/dashboard/post/{mypost.id}/"
response = self.client.get(url)
parsed = lxml.html.fromstring(response.content)
forms = parsed.cssselect("form[method=POST]")
@@ -87,62 +82,62 @@ class NoteViewTest(TestCase):
inputs = form.cssselect("input")
self.assertIn("text", [input.get("name") for input in inputs])
def test_displays_only_items_for_that_note(self):
def test_displays_only_lines_for_that_post(self):
# Given/Arrange
correct_note = Note.objects.create()
Item.objects.create(text="itemey 1", note=correct_note)
Item.objects.create(text="itemey 2", note=correct_note)
other_note = Note.objects.create()
Item.objects.create(text="other note item", note=other_note)
correct_post = Post.objects.create()
Line.objects.create(text="itemey 1", post=correct_post)
Line.objects.create(text="itemey 2", post=correct_post)
other_post = Post.objects.create()
Line.objects.create(text="other post line", post=other_post)
# When/Act
response = self.client.get(f"/dashboard/note/{correct_note.id}/")
response = self.client.get(f"/dashboard/post/{correct_post.id}/")
# Then/Assert
self.assertContains(response, "itemey 1")
self.assertContains(response, "itemey 2")
self.assertNotContains(response, "other note item")
self.assertNotContains(response, "other post line")
def test_can_save_a_POST_request_to_an_existing_note(self):
other_note = Note.objects.create()
correct_note = Note.objects.create()
def test_can_save_a_POST_request_to_an_existing_post(self):
other_post = Post.objects.create()
correct_post = Post.objects.create()
self.client.post(
f"/dashboard/note/{correct_note.id}/",
data={"text": "A new item for an existing note"},
f"/dashboard/post/{correct_post.id}/",
data={"text": "A new line for an existing post"},
)
self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.get()
self.assertEqual(new_item.text, "A new item for an existing note")
self.assertEqual(new_item.note, correct_note)
self.assertEqual(Line.objects.count(), 1)
new_line = Line.objects.get()
self.assertEqual(new_line.text, "A new line for an existing post")
self.assertEqual(new_line.post, correct_post)
def test_POST_redirects_to_note_view(self):
other_note = Note.objects.create()
correct_note = Note.objects.create()
def test_POST_redirects_to_post_view(self):
other_post = Post.objects.create()
correct_post = Post.objects.create()
response = self.client.post(
f"/dashboard/note/{correct_note.id}/",
data={"text": "A new item for an existing note"},
f"/dashboard/post/{correct_post.id}/",
data={"text": "A new line for an existing post"},
)
self.assertRedirects(response, f"/dashboard/note/{correct_note.id}/")
self.assertRedirects(response, f"/dashboard/post/{correct_post.id}/")
# Post invalid input helper
def post_invalid_input(self):
mynote = Note.objects.create()
return self.client.post(f"/dashboard/note/{mynote.id}/", data={"text": ""})
mypost = Post.objects.create()
return self.client.post(f"/dashboard/post/{mypost.id}/", data={"text": ""})
def test_for_invalid_input_nothing_saved_to_db(self):
self.post_invalid_input()
self.assertEqual(Item.objects.count(), 0)
self.assertEqual(Line.objects.count(), 0)
def test_for_invalid_input_renders_note_template(self):
def test_for_invalid_input_renders_post_template(self):
response = self.post_invalid_input()
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/dashboard/note.html")
self.assertTemplateUsed(response, "apps/dashboard/post.html")
def test_for_invalid_input_shows_error_on_page(self):
response = self.post_invalid_input()
self.assertContains(response, html.escape(EMPTY_ITEM_ERROR))
self.assertContains(response, html.escape(EMPTY_LINE_ERROR))
def test_for_invalid_input_sets_is_invalid_class(self):
response = self.post_invalid_input()
@@ -150,26 +145,26 @@ class NoteViewTest(TestCase):
[input] = parsed.cssselect("input[name=text]")
self.assertIn("is-invalid", set(input.classes))
def test_duplicate_item_validation_errors_end_up_on_note_page(self):
note1 = Note.objects.create()
Item.objects.create(note=note1, text="lorem ipsum")
def test_duplicate_line_validation_errors_end_up_on_post_page(self):
post1 = Post.objects.create()
Line.objects.create(post=post1, text="lorem ipsum")
response = self.client.post(
f"/dashboard/note/{note1.id}/",
f"/dashboard/post/{post1.id}/",
data={"text": "lorem ipsum"},
)
expected_error = html.escape(DUPLICATE_ITEM_ERROR)
expected_error = html.escape(DUPLICATE_LINE_ERROR)
self.assertContains(response, expected_error)
self.assertTemplateUsed(response, "apps/dashboard/note.html")
self.assertEqual(Item.objects.all().count(), 1)
self.assertTemplateUsed(response, "apps/dashboard/post.html")
self.assertEqual(Line.objects.all().count(), 1)
class MyNotesTest(TestCase):
def test_my_notes_url_renders_my_notes_template(self):
class MyPostsTest(TestCase):
def test_my_posts_url_renders_my_posts_template(self):
user = User.objects.create(email="a@b.cde")
self.client.force_login(user)
response = self.client.get(f"/dashboard/users/{user.id}/")
self.assertTemplateUsed(response, "apps/dashboard/my_notes.html")
self.assertTemplateUsed(response, "apps/dashboard/my_posts.html")
def test_passes_correct_owner_to_template(self):
User.objects.create(email="wrongowner@example.com")
@@ -178,71 +173,69 @@ class MyNotesTest(TestCase):
response = self.client.get(f"/dashboard/users/{correct_user.id}/")
self.assertEqual(response.context["owner"], correct_user)
def test_note_owner_is_saved_if_user_is_authenticated(self):
def test_post_owner_is_saved_if_user_is_authenticated(self):
user = User.objects.create(email="a@b.cde")
self.client.force_login(user)
self.client.post("/dashboard/new_note", data={"text": "new item"})
new_note = Note.objects.get()
self.assertEqual(new_note.owner, user)
self.client.post("/dashboard/new_post", data={"text": "new line"})
new_post = Post.objects.get()
self.assertEqual(new_post.owner, user)
def test_my_notes_redirects_if_not_logged_in(self):
user = User.objects.create(email="a@b.cde")
def test_my_posts_redirects_if_not_logged_in(self):
user = User.objects.create(email="a@b.cde")
response = self.client.get(f"/dashboard/users/{user.id}/")
self.assertRedirects(response, "/")
def test_my_notes_returns_403_for_wrong_user(self):
# create two users, login as user_a, request user_b's my_notes url
def test_my_posts_returns_403_for_wrong_user(self):
user1 = User.objects.create(email="a@b.cde")
user2 = User.objects.create(email="wrongowner@example.com")
self.client.force_login(user2)
response = self.client.get(f"/dashboard/users/{user1.id}/")
# assert 403
self.assertEqual(response.status_code, 403)
class ShareNoteTest(TestCase):
def test_post_to_share_note_url_redirects_to_note(self):
our_note = Note.objects.create()
class SharePostTest(TestCase):
def test_post_to_share_post_url_redirects_to_post(self):
our_post = Post.objects.create()
alice = User.objects.create(email="alice@example.com")
response = self.client.post(
f"/dashboard/note/{our_note.id}/share_note",
f"/dashboard/post/{our_post.id}/share_post",
data={"recipient": "alice@example.com"},
)
self.assertRedirects(response, f"/dashboard/note/{our_note.id}/")
self.assertRedirects(response, f"/dashboard/post/{our_post.id}/")
def test_post_with_email_adds_user_to_shared_with(self):
our_note = Note.objects.create()
our_post = Post.objects.create()
alice = User.objects.create(email="alice@example.com")
self.client.post(
f"/dashboard/note/{our_note.id}/share_note",
f"/dashboard/post/{our_post.id}/share_post",
data={"recipient": "alice@example.com"},
)
self.assertIn(alice, our_note.shared_with.all())
self.assertIn(alice, our_post.shared_with.all())
def test_post_with_nonexistent_email_redirects_to_note(self):
our_note = Note.objects.create()
def test_post_with_nonexistent_email_redirects_to_post(self):
our_post = Post.objects.create()
response = self.client.post(
f"/dashboard/note/{our_note.id}/share_note",
f"/dashboard/post/{our_post.id}/share_post",
data={"recipient": "nobody@example.com"},
)
self.assertRedirects(
response,
f"/dashboard/note/{our_note.id}/",
f"/dashboard/post/{our_post.id}/",
fetch_redirect_response=False,
)
def test_share_note_does_not_add_owner_as_recipient(self):
def test_share_post_does_not_add_owner_as_recipient(self):
owner = User.objects.create(email="owner@example.com")
our_note = Note.objects.create(owner=owner)
our_post = Post.objects.create(owner=owner)
self.client.force_login(owner)
self.client.post(reverse("share_note", args=[our_note.id]),
self.client.post(reverse("share_post", args=[our_post.id]),
data={"recipient": "owner@example.com"})
self.assertNotIn(owner, our_note.shared_with.all())
self.assertNotIn(owner, our_post.shared_with.all())
@override_settings(MESSAGE_STORAGE='django.contrib.messages.storage.session.SessionStorage')
def test_share_note_shows_privacy_safe_message(self):
our_note = Note.objects.create()
def test_share_post_shows_privacy_safe_message(self):
our_post = Post.objects.create()
response = self.client.post(
f"/dashboard/note/{our_note.id}/share_note",
f"/dashboard/post/{our_post.id}/share_post",
data={"recipient": "nobody@example.com"},
follow=True,
)
@@ -252,26 +245,26 @@ class ShareNoteTest(TestCase):
"An invite has been sent if that address is registered.",
)
class ViewAuthNoteTest(TestCase):
class ViewAuthPostTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="disco@example.com")
self.our_note = Note.objects.create(owner=self.owner)
self.our_post = Post.objects.create(owner=self.owner)
def test_anonymous_user_is_redirected(self):
response = self.client.get(reverse("view_note", args=[self.our_note.id]))
response = self.client.get(reverse("view_post", args=[self.our_post.id]))
self.assertRedirects(response, "/", fetch_redirect_response=False)
def test_non_owner_non_shared_user_gets_403(self):
stranger = User.objects.create(email="stranger@example.com")
self.client.force_login(stranger)
response = self.client.get(reverse("view_note", args=[self.our_note.id]))
response = self.client.get(reverse("view_post", args=[self.our_post.id]))
self.assertEqual(response.status_code, 403)
def test_shared_with_user_can_access_note(self):
def test_shared_with_user_can_access_post(self):
guest = User.objects.create(email="guest@example.com")
self.our_note.shared_with.add(guest)
self.our_post.shared_with.add(guest)
self.client.force_login(guest)
response = self.client.get(reverse("view_note", args=[self.our_note.id]))
response = self.client.get(reverse("view_post", args=[self.our_post.id]))
self.assertEqual(response.status_code, 200)
@override_settings(COMPRESS_ENABLED=False)
@@ -349,14 +342,14 @@ class SetPaletteTest(TestCase):
swatches = parsed.cssselect(".swatch")
self.assertEqual(len(swatches), len(response.context["palettes"]))
class RecognitionPaletteContextTest(TestCase):
class NotePaletteContextTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="recog_palette@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
def test_recognition_palette_unlocks_swatch_in_context(self):
Recognition.objects.create(
def test_note_palette_unlocks_swatch_in_context(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
@@ -365,8 +358,8 @@ class RecognitionPaletteContextTest(TestCase):
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertFalse(bardo["locked"])
def test_recognition_palette_shoptalk_contains_recognition_title(self):
Recognition.objects.create(
def test_note_palette_shoptalk_contains_note_title(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
@@ -375,8 +368,8 @@ class RecognitionPaletteContextTest(TestCase):
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertIn("Stargazer", bardo["shoptalk"])
def test_recognition_without_palette_field_keeps_swatch_locked(self):
Recognition.objects.create(
def test_note_without_palette_field_keeps_swatch_locked(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette=None,
)
@@ -385,8 +378,8 @@ class RecognitionPaletteContextTest(TestCase):
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertTrue(bardo["locked"])
def test_recognition_palette_allows_set_palette_via_view(self):
Recognition.objects.create(
def test_note_palette_allows_set_palette_via_view(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)

View File

@@ -1,13 +1,13 @@
from django.test import SimpleTestCase
from apps.dashboard.forms import (
EMPTY_ITEM_ERROR,
ItemForm,
EMPTY_LINE_ERROR,
LineForm,
)
class SimpleItemFormTest(SimpleTestCase):
def test_form_validation_for_blank_items(self):
form = ItemForm(data={"text": ""})
class SimpleLineFormTest(SimpleTestCase):
def test_form_validation_for_blank_lines(self):
form = LineForm(data={"text": ""})
self.assertFalse(form.is_valid())
self.assertEqual(form.errors["text"], [EMPTY_ITEM_ERROR])
self.assertEqual(form.errors["text"], [EMPTY_LINE_ERROR])

View File

@@ -1,13 +1,13 @@
from django.test import SimpleTestCase
from apps.dashboard.models import Item
from apps.dashboard.models import Line
class SimpleItemModelTest(SimpleTestCase):
class SimpleLineModelTest(SimpleTestCase):
def test_default_text(self):
item = Item()
self.assertEqual(item.text, "")
line = Line()
self.assertEqual(line.text, "")
def test_string_representation(self):
item = Item(text="sample text")
self.assertEqual(str(item), "sample text")
line = Line(text="sample text")
self.assertEqual(str(line), "sample text")

View File

@@ -2,12 +2,12 @@ from django.urls import path
from . import views
urlpatterns = [
path('new_note', views.new_note, name='new_note'),
path('note/<uuid:note_id>/', views.view_note, name='view_note'),
path('note/<uuid:note_id>/share_note', views.share_note, name="share_note"),
path('new_post', views.new_post, name='new_post'),
path('post/<uuid:post_id>/', views.view_post, name='view_post'),
path('post/<uuid:post_id>/share_post', views.share_post, name="share_post"),
path('set_palette', views.set_palette, name='set_palette'),
path('set_profile', views.set_profile, name='set_profile'),
path('users/<uuid:user_id>/', views.my_notes, name='my_notes'),
path('users/<uuid:user_id>/', views.my_posts, name='my_posts'),
path('toggle_applets', views.toggle_applets, name="toggle_applets"),
path('wallet/', views.wallet, name='wallet'),
path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'),

View File

@@ -15,14 +15,14 @@ from django.utils import timezone
from django.views.decorators.csrf import ensure_csrf_cookie
from apps.applets.utils import applet_context, apply_applet_toggle
from apps.dashboard.forms import ExistingNoteItemForm, ItemForm
from apps.dashboard.models import Item, Note
from apps.drama.models import Recognition
from apps.dashboard.forms import ExistingPostLineForm, LineForm
from apps.dashboard.models import Line, Post
from apps.drama.models import Note
from apps.epic.utils import _compute_distinctions
from apps.lyric.models import PaymentMethod, Token, User, Wallet
APPLET_ORDER = ["wallet", "new-note", "my-notes", "username", "palette"]
APPLET_ORDER = ["wallet", "username", "palette"]
_BASE_UNLOCKED = frozenset([
"palette-default",
"palette-cedar",
@@ -40,7 +40,7 @@ _PALETTE_DEFS = [
{"name": "palette-terrestre", "label": "Terrestre", "locked": True},
{"name": "palette-celestia", "label": "Celestia", "locked": True},
]
_RECOGNITION_TITLES = {
_NOTE_TITLES = {
"stargazer": "Stargazer",
"schizo": "Schizo",
"nomad": "Nomad",
@@ -54,7 +54,7 @@ def _palettes_for_user(user):
return [dict(p, shoptalk="Placeholder") for p in _PALETTE_DEFS]
granted = {
r.palette: r
for r in Recognition.objects.filter(user=user, palette__isnull=False).exclude(palette="")
for r in Note.objects.filter(user=user, palette__isnull=False).exclude(palette="")
}
result = []
for p in _PALETTE_DEFS:
@@ -62,7 +62,7 @@ def _palettes_for_user(user):
r = granted.get(p["name"])
if r and p["locked"]:
entry["locked"] = False
title = _RECOGNITION_TITLES.get(r.slug, r.slug.capitalize())
title = _NOTE_TITLES.get(r.slug, r.slug.capitalize())
entry["shoptalk"] = f"{title} · {r.earned_at.strftime('%b %d, %Y').replace(' 0', ' ')}"
else:
entry["shoptalk"] = "Placeholder"
@@ -73,89 +73,86 @@ def _palettes_for_user(user):
def _unlocked_palettes_for_user(user):
base = set(_BASE_UNLOCKED)
if user and user.is_authenticated:
for r in Recognition.objects.filter(user=user, palette__isnull=False).exclude(palette=""):
for r in Note.objects.filter(user=user, palette__isnull=False).exclude(palette=""):
base.add(r.palette)
return base
def _recent_notes(user, limit=3):
def _recent_posts(user, limit=3):
return (
Note
Post
.objects
.filter(Q(owner=user) | Q(shared_with=user))
.annotate(last_item=Max('item__id'))
.order_by('-last_item')
.annotate(last_line=Max('lines__id'))
.order_by('-last_line')
.distinct()[:limit]
)
def home_page(request):
context = {
"form": ItemForm(),
"palettes": _palettes_for_user(request.user),
"page_class": "page-dashboard",
}
if request.user.is_authenticated:
context["applets"] = applet_context(request.user, "dashboard")
context["recent_notes"] = _recent_notes(request.user)
return render(request, "apps/dashboard/home.html", context)
def new_note(request):
form = ItemForm(data=request.POST)
def new_post(request):
form = LineForm(data=request.POST)
if form.is_valid():
nunote = Note.objects.create()
nupost = Post.objects.create()
if request.user.is_authenticated:
nunote.owner = request.user
nunote.save()
form.save(for_note=nunote)
return redirect(nunote)
nupost.owner = request.user
nupost.save()
form.save(for_post=nupost)
return redirect(nupost)
else:
context = {
"form": form,
"palettes": _palettes_for_user(request.user),
"page_class": "page-dashboard",
"page_class": "page-billboard",
}
if request.user.is_authenticated:
context["applets"] = applet_context(request.user, "dashboard")
context["recent_notes"] = _recent_notes(request.user)
return render(request, "apps/dashboard/home.html", context)
context["applets"] = applet_context(request.user, "billboard")
context["recent_posts"] = _recent_posts(request.user)
return render(request, "apps/billboard/billboard.html", context)
def view_note(request, note_id):
our_note = Note.objects.get(id=note_id)
def view_post(request, post_id):
our_post = Post.objects.get(id=post_id)
if our_note.owner:
if our_post.owner:
if not request.user.is_authenticated:
return redirect("/")
if request.user != our_note.owner and request.user not in our_note.shared_with.all():
if request.user != our_post.owner and request.user not in our_post.shared_with.all():
return HttpResponseForbidden()
form = ExistingNoteItemForm(for_note=our_note)
form = ExistingPostLineForm(for_post=our_post)
if request.method == "POST":
form = ExistingNoteItemForm(for_note=our_note, data=request.POST)
form = ExistingPostLineForm(for_post=our_post, data=request.POST)
if form.is_valid():
form.save()
return redirect(our_note)
return render(request, "apps/dashboard/note.html", {"note": our_note, "form": form})
return redirect(our_post)
return render(request, "apps/dashboard/post.html", {"post": our_post, "form": form})
def my_notes(request, user_id):
def my_posts(request, user_id):
owner = User.objects.get(id=user_id)
if not request.user.is_authenticated:
return redirect("/")
if request.user.id != owner.id:
return HttpResponseForbidden()
return render(request, "apps/dashboard/my_notes.html", {"owner": owner})
return render(request, "apps/dashboard/my_posts.html", {"owner": owner})
def share_note(request, note_id):
our_note = Note.objects.get(id=note_id)
def share_post(request, post_id):
our_post = Post.objects.get(id=post_id)
try:
recipient = User.objects.get(email=request.POST["recipient"])
if recipient == request.user:
return redirect(our_note)
our_note.shared_with.add(recipient)
return redirect(our_post)
our_post.shared_with.add(recipient)
except User.DoesNotExist:
pass
messages.success(request, "An invite has been sent if that address is registered.")
return redirect(our_note)
return redirect(our_post)
@login_required(login_url="/")
def set_palette(request):
@@ -184,8 +181,6 @@ def toggle_applets(request):
return render(request, "apps/dashboard/_partials/_applets.html", {
"applets": applet_context(request.user, "dashboard"),
"palettes": _palettes_for_user(request.user),
"form": ItemForm(),
"recent_notes": _recent_notes(request.user),
})
return redirect("home")
@@ -410,18 +405,18 @@ def sky_save(request):
'sky_birth_place', 'sky_birth_tz', 'sky_house_system', 'sky_chart_data',
])
recognition_payload = None
note_payload = None
if user.sky_chart_data:
recog, created = Recognition.grant_if_new(user, "stargazer")
note, created = Note.grant_if_new(user, "stargazer")
if created:
recognition_payload = {
"slug": recog.slug,
note_payload = {
"slug": note.slug,
"title": "Stargazer",
"description": "You saved your first personal sky chart.",
"earned_at": recog.earned_at.isoformat(),
"earned_at": note.earned_at.isoformat(),
}
return JsonResponse({"saved": True, "recognition": recognition_payload})
return JsonResponse({"saved": True, "note": note_payload})
@login_required(login_url="/")

View File

@@ -0,0 +1,24 @@
import django.conf
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('drama', '0004_recognition'),
migrations.swappable_dependency(django.conf.settings.AUTH_USER_MODEL),
]
operations = [
migrations.RenameModel('Recognition', 'Note'),
migrations.AlterField(
model_name='note',
name='user',
field=models.ForeignKey(
django.conf.settings.AUTH_USER_MODEL,
on_delete=django.db.models.deletion.CASCADE,
related_name='notes',
),
),
]

View File

@@ -170,10 +170,10 @@ def record(room, verb, actor=None, **data):
return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)
class Recognition(models.Model):
class Note(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
related_name="recognitions",
related_name="notes",
)
slug = models.SlugField(max_length=60)
earned_at = models.DateTimeField()

View File

@@ -2,7 +2,7 @@ from django.test import TestCase
from django.db import IntegrityError
from django.utils import timezone
from apps.drama.models import GameEvent, Recognition, ScrollPosition, record
from apps.drama.models import GameEvent, Note, ScrollPosition, record
from apps.epic.models import Room
from apps.lyric.models import User
@@ -176,44 +176,44 @@ class ScrollPositionModelTest(TestCase):
self.assertEqual(ScrollPosition.objects.get(user=self.user, room=self.room).position, 200)
class RecognitionModelTest(TestCase):
class NoteModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="earner@test.io")
def test_can_create_recognition(self):
recog = Recognition.objects.create(
recog = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
self.assertEqual(Recognition.objects.count(), 1)
self.assertEqual(Note.objects.count(), 1)
self.assertEqual(recog.slug, "stargazer")
self.assertEqual(recog.user, self.user)
def test_palette_is_null_by_default(self):
recog = Recognition.objects.create(
recog = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
self.assertIsNone(recog.palette)
def test_palette_can_be_set(self):
recog = Recognition.objects.create(
recog = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
self.assertEqual(recog.palette, "palette-bardo")
def test_unique_per_user_and_slug(self):
Recognition.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
Note.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
with self.assertRaises(IntegrityError):
Recognition.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
Note.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
def test_different_users_can_share_slug(self):
other = User.objects.create(email="other@test.io")
Recognition.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
Recognition.objects.create(user=other, slug="stargazer", earned_at=timezone.now())
self.assertEqual(Recognition.objects.count(), 2)
Note.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
Note.objects.create(user=other, slug="stargazer", earned_at=timezone.now())
self.assertEqual(Note.objects.count(), 2)
def test_str_includes_slug_and_email(self):
recog = Recognition.objects.create(
recog = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
s = str(recog)
@@ -221,22 +221,22 @@ class RecognitionModelTest(TestCase):
self.assertIn("earner@test.io", s)
def test_grant_if_new_creates_on_first_call(self):
recog, created = Recognition.grant_if_new(self.user, "stargazer")
recog, created = Note.grant_if_new(self.user, "stargazer")
self.assertTrue(created)
self.assertEqual(recog.slug, "stargazer")
self.assertIsNotNone(recog.earned_at)
def test_grant_if_new_is_idempotent(self):
Recognition.grant_if_new(self.user, "stargazer")
recog, created = Recognition.grant_if_new(self.user, "stargazer")
Note.grant_if_new(self.user, "stargazer")
recog, created = Note.grant_if_new(self.user, "stargazer")
self.assertFalse(created)
self.assertEqual(Recognition.objects.count(), 1)
self.assertEqual(Note.objects.count(), 1)
def test_grant_if_new_does_not_overwrite_palette(self):
Recognition.objects.create(
Note.objects.create(
user=self.user, slug="stargazer",
earned_at=timezone.now(), palette="palette-bardo",
)
recog, created = Recognition.grant_if_new(self.user, "stargazer")
recog, created = Note.grant_if_new(self.user, "stargazer")
self.assertFalse(created)
self.assertEqual(recog.palette, "palette-bardo")

View File

@@ -52,7 +52,7 @@ class FunctionalTest(StaticLiveServerTestCase):
if self.test_server:
self.live_server_url = 'http://' + self.test_server
reset_database(self.test_server)
Applet.objects.get_or_create(slug="new-note", defaults={"name": "New Note"})
Applet.objects.get_or_create(slug="new-post", defaults={"name": "New Post", "context": "billboard"})
def tearDown(self):
if self._test_has_failed():
@@ -156,7 +156,7 @@ class ChannelsFunctionalTest(ChannelsLiveServerTestCase):
if self.test_server:
self.live_server_url = 'http://' + self.test_server
reset_database(self.test_server)
Applet.objects.get_or_create(slug="new-note", defaults={"name": "New Note"})
Applet.objects.get_or_create(slug="new-post", defaults={"name": "New Post", "context": "billboard"})
def tearDown(self):
if self._test_has_failed():

View File

@@ -3,11 +3,11 @@ from selenium.webdriver.common.by import By
from apps.lyric.models import User
class MyNotesPage:
class MyPostsPage:
def __init__(self, test):
self.test = test
def go_to_my_notes_page(self, email):
def go_to_my_posts_page(self, email):
self.test.browser.get(self.test.live_server_url)
user = User.objects.get(email=email)
self.test.browser.get(

View File

@@ -4,27 +4,27 @@ from selenium.webdriver.common.keys import Keys
from .base import wait
class NotePage:
class PostPage:
def __init__(self, test):
self.test = test
def get_table_rows(self):
return self.test.browser.find_elements(By.CSS_SELECTOR, "#id_note_table tr")
return self.test.browser.find_elements(By.CSS_SELECTOR, "#id_post_table tr")
@wait
def wait_for_row_in_note_table(self, item_text, item_number):
expected_row_text = f"{item_number}. {item_text}"
def wait_for_row_in_post_table(self, line_text, line_number):
expected_row_text = f"{line_number}. {line_text}"
rows = self.get_table_rows()
self.test.assertIn(expected_row_text, [row.text for row in rows])
def get_item_input_box(self):
def get_line_input_box(self):
return self.test.browser.find_element(By.ID, "id_text")
def add_note_item(self, item_text):
new_item_no = len(self.get_table_rows()) + 1
self.get_item_input_box().send_keys(item_text)
self.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for_row_in_note_table(item_text, new_item_no)
def add_post_line(self, line_text):
new_line_no = len(self.get_table_rows()) + 1
self.get_line_input_box().send_keys(line_text)
self.get_line_input_box().send_keys(Keys.ENTER)
self.wait_for_row_in_post_table(line_text, new_line_no)
return self
def get_share_box(self):
@@ -36,10 +36,10 @@ class NotePage:
def get_shared_with_list(self):
return self.test.browser.find_elements(
By.CSS_SELECTOR,
".note-recipient"
".post-recipient"
)
def share_note_with(self, email):
def share_post_with(self, email):
self.get_share_box().send_keys(email)
self.get_share_box().send_keys(Keys.ENTER)
self.test.wait_for(
@@ -48,5 +48,5 @@ class NotePage:
)
)
def get_note_owner(self):
return self.test.browser.find_element(By.ID, "id_note_owner").text
def get_post_owner(self):
return self.test.browser.find_element(By.ID, "id_post_owner").text

View File

@@ -1,44 +1,350 @@
"""Functional tests for the Note system.
Note is Earthman's achievement analogue — account-level unlocks earned when
the socius observes a qualifying action. These tests cover the Stargazer Note:
earned on the first valid personal sky save (dashboard My Sky applet or sky page),
outside of any game room.
Two test classes — one per surface that can trigger the unlock — each asserting both
the negative (disabled/incomplete save does nothing) and positive (first valid save
fires the banner) conditions.
T2 (Dashboard full flow) is split across three focused tests:
T2a — save → banner → FYI → recognition page item
T2b — palette modal flow on recognition page
T2c — dashboard palette applet reflects Note palette unlock
"""
import json as _json
from django.utils import timezone
from selenium.webdriver.common.by import By
from apps.applets.models import Applet
from apps.drama.models import Note
from apps.lyric.models import User
from .base import FunctionalTest
from .note_page import NotePage
from .my_notes_page import MyNotesPage
class MyNotesTest(FunctionalTest):
# Shared natal chart fixture — same birth data as test_applet_my_sky.py.
_CHART_FIXTURE = {
"planets": {
"Sun": {"sign": "Gemini", "degree": 66.7, "retrograde": False},
"Moon": {"sign": "Taurus", "degree": 43.0, "retrograde": False},
"Mercury": {"sign": "Taurus", "degree": 55.0, "retrograde": False},
"Venus": {"sign": "Gemini", "degree": 63.3, "retrograde": False},
"Mars": {"sign": "Leo", "degree": 132.0, "retrograde": False},
"Jupiter": {"sign": "Capricorn", "degree": 292.0, "retrograde": True},
"Saturn": {"sign": "Virgo", "degree": 153.0, "retrograde": False},
"Uranus": {"sign": "Pisces", "degree": 322.0, "retrograde": False},
"Neptune": {"sign": "Aquarius", "degree": 323.0, "retrograde": True},
"Pluto": {"sign": "Sagittarius", "degree": 269.0, "retrograde": True},
},
"houses": {
"cusps": [180, 210, 240, 270, 300, 330, 0, 30, 60, 90, 120, 150],
"asc": 180.0, "mc": 90.0,
},
"elements": {"Fire": 1, "Water": 0, "Stone": 2, "Air": 4, "Time": 1, "Space": 1},
"aspects": [],
"distinctions": {
"1": 0, "2": 0, "3": 2, "4": 0, "5": 0, "6": 0,
"7": 1, "8": 0, "9": 2, "10": 1, "11": 1, "12": 2,
},
"house_system": "O",
"timezone": "Europe/London",
}
def test_logged_in_users_notes_are_saved_as_my_notes(self):
self.create_pre_authenticated_session("disco@test.io")
def _mock_preview_js(fixture):
"""Intercepts /sky/preview with a fixture; lets /sky/save reach the real server."""
return f"""
const FIXTURE = {_json.dumps(fixture)};
window._origFetch = window.fetch;
window.fetch = function(url, opts) {{
if (url.includes('/sky/preview')) {{
return Promise.resolve({{
ok: true,
json: () => Promise.resolve(FIXTURE),
}});
}}
return window._origFetch(url, opts);
}};
"""
def _fill_valid_sky_form(browser):
"""Populate form fields and fire input events to trigger schedulePreview."""
browser.execute_script("""
document.getElementById('id_nf_date').value = '1990-06-15';
document.getElementById('id_nf_lat').value = '51.5074';
document.getElementById('id_nf_lon').value = '-0.1278';
document.getElementById('id_nf_tz').value = 'Europe/London';
document.getElementById('id_nf_date').dispatchEvent(
new Event('input', {bubbles: true})
);
""")
# ──────────────────────────────────────────────────────────────────────────────
# Surface A — My Sky applet on the Dashboard
# ──────────────────────────────────────────────────────────────────────────────
class StargazerNoteFromDashboardTest(FunctionalTest):
"""Stargazer Note triggered from the My Sky applet."""
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
Applet.objects.get_or_create(
slug="my-sky",
defaults={"name": "My Sky", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"},
)
Applet.objects.get_or_create(
slug="palette",
defaults={"name": "Palettes", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"},
)
Applet.objects.get_or_create(
slug="billboard-notes",
defaults={"name": "Note", "grid_cols": 4, "grid_rows": 4, "context": "billboard"},
)
self.gamer = User.objects.create(email="stargazer@test.io")
# ── T1 ───────────────────────────────────────────────────────────────────
def test_disabled_save_button_does_not_unlock_note(self):
"""SAVE SKY is disabled before a valid preview fires.
No Note banner appears while the button remains disabled."""
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.live_server_url)
note_page = NotePage(self)
note_page.add_note_item("Reticulate splines")
note_page.add_note_item("Regurgitate spines")
first_note_url = self.browser.current_url
MyNotesPage(self).go_to_my_notes_page("disco@test.io")
self.wait_for(
lambda: self.browser.find_element(By.LINK_TEXT, "Reticulate splines")
)
self.browser.find_element(By.LINK_TEXT, "Reticulate splines").click()
self.wait_for(
lambda: self.assertEqual(self.browser.current_url, first_note_url)
confirm_btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_natus_confirm")
)
self.assertIsNotNone(confirm_btn.get_attribute("disabled"))
self.assertFalse(self.browser.find_elements(By.CSS_SELECTOR, ".note-banner"))
# ── T2a ──────────────────────────────────────────────────────────────────
def test_first_valid_save_from_applet_fires_banner_and_leads_to_note_page(self):
"""First valid SAVE SKY from the My Sky applet fires the Stargazer banner.
FYI button navigates to /billboard/my-notes/ showing the Stargazer item."""
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.live_server_url)
note_page.add_note_item("Ribbon of death")
second_note_url = self.browser.current_url
MyNotesPage(self).go_to_my_notes_page("disco@test.io")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_applet_sky_form_wrap"))
self.browser.execute_script(_mock_preview_js(_CHART_FIXTURE))
_fill_valid_sky_form(self.browser)
confirm_btn = self.browser.find_element(By.ID, "id_natus_confirm")
self.wait_for(lambda: self.assertIsNone(confirm_btn.get_attribute("disabled")))
confirm_btn.click()
# Banner slides in below the Dash h2
banner = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner")
)
self.assertIn(
"Stargazer",
banner.find_element(By.CSS_SELECTOR, ".note-banner__title").text,
)
banner.find_element(By.CSS_SELECTOR, ".note-banner__description")
banner.find_element(By.CSS_SELECTOR, ".note-banner__timestamp")
banner.find_element(By.CSS_SELECTOR, ".note-banner__image")
banner.find_element(By.CSS_SELECTOR, ".btn.btn-danger") # NVM
fyi = banner.find_element(By.CSS_SELECTOR, ".btn.btn-caution") # FYI
# FYI navigates to Note page
fyi.click()
self.wait_for(
lambda: self.browser.find_element(By.LINK_TEXT, "Ribbon of death")
lambda: self.assertRegex(self.browser.current_url, r"/billboard/recognition")
)
self.browser.find_element(By.CSS_SELECTOR, "#id_logout").click()
self.wait_for(
lambda: self.assertEqual(
self.browser.find_elements(By.LINK_TEXT, "My notes"),
[],
)
# Note page: one Stargazer item
item = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-list .note-item")
)
self.assertIn(
"Stargazer",
item.find_element(By.CSS_SELECTOR, ".note-item__title").text,
)
item.find_element(By.CSS_SELECTOR, ".note-item__description")
item.find_element(By.CSS_SELECTOR, ".note-item__image-box")
# ── T2b ──────────────────────────────────────────────────────────────────
def test_note_page_palette_modal_flow(self):
"""Note page palette modal: image-box opens modal, swatch preview,
body-click restores modal, OK raises confirm submenu, confirm sets palette."""
Note.objects.create(
user=self.gamer, slug="stargazer", earned_at=timezone.now(),
)
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.live_server_url + "/billboard/my-notes/")
image_box = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-item__image-box")
)
# Clicking ? opens palette modal
image_box.click()
modal = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-palette-modal")
)
modal.find_element(By.CSS_SELECTOR, ".palette-bardo")
modal.find_element(By.CSS_SELECTOR, ".palette-sheol")
# Clicking a swatch body previews the palette and dismisses the modal
bardo_body = modal.find_element(By.CSS_SELECTOR, ".palette-bardo .note-swatch-body")
self.browser.execute_script(
"arguments[0].dispatchEvent(new MouseEvent('click', {bubbles: true}))",
bardo_body,
)
self.wait_for(lambda: self.assertFalse(
self.browser.find_elements(By.CSS_SELECTOR, ".note-palette-modal")
))
# Clicking elsewhere ends preview and restores the modal
self.browser.find_element(By.TAG_NAME, "body").click()
modal = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-palette-modal")
)
# Clicking OK on the swatch raises a confirmation submenu
ok_btn = modal.find_element(By.CSS_SELECTOR, ".palette-bardo .btn.btn-confirm")
ok_btn.click()
confirm_menu = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-palette-confirm")
)
# Confirming sets palette, closes modal, replaces image-box with palette swatch
confirm_menu.find_element(By.CSS_SELECTOR, ".btn.btn-confirm").click()
self.wait_for(lambda: self.assertFalse(
self.browser.find_elements(By.CSS_SELECTOR, ".note-palette-modal")
))
item = self.browser.find_element(By.CSS_SELECTOR, ".note-item")
self.assertTrue(
item.find_elements(By.CSS_SELECTOR, ".note-item__palette.palette-bardo")
)
self.assertFalse(
item.find_elements(By.CSS_SELECTOR, ".note-item__image-box")
)
# ── T2c ──────────────────────────────────────────────────────────────────
def test_dashboard_palette_applet_reflects_note_palette_unlock(self):
"""After palette unlock via Note, the Dashboard Palette applet shows
the palette swatch as unlocked with Stargazer shoptalk."""
Note.objects.create(
user=self.gamer, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.live_server_url)
palette_applet = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_palette")
)
bardo = palette_applet.find_element(By.CSS_SELECTOR, ".swatch.palette-bardo")
self.assertNotIn("locked", bardo.get_attribute("class"))
bardo_ok = bardo.find_element(By.CSS_SELECTOR, ".palette-ok")
self.assertIn("btn-confirm", bardo_ok.get_attribute("class"))
self.assertNotIn("btn-disabled", bardo_ok.get_attribute("class"))
self.assertIn("Stargazer", bardo.get_attribute("data-shoptalk"))
# ──────────────────────────────────────────────────────────────────────────────
# Surface B — /dashboard/sky/ standalone page
# ──────────────────────────────────────────────────────────────────────────────
class StargazerNoteFromSkyPageTest(FunctionalTest):
"""Stargazer Note triggered from the standalone sky page.
T3 — disabled save on sky page does not fire Note.
T4 — first valid save fires banner; NVM dismisses it.
T5 — already-earned Note does not re-show banner on subsequent save.
"""
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
Applet.objects.get_or_create(
slug="my-sky",
defaults={"name": "My Sky", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"},
)
Applet.objects.get_or_create(
slug="billboard-notes",
defaults={"name": "Note", "grid_cols": 4, "grid_rows": 4, "context": "billboard"},
)
self.gamer = User.objects.create(email="stargazer@test.io")
self.sky_url = self.live_server_url + "/dashboard/sky/"
# ── T3 ───────────────────────────────────────────────────────────────────
def test_disabled_save_on_sky_page_does_not_unlock_note(self):
"""On /dashboard/sky/, SAVE SKY is disabled until preview fires.
No Note banner appears while the button is disabled."""
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.sky_url)
confirm_btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_natus_confirm")
)
self.assertIsNotNone(confirm_btn.get_attribute("disabled"))
self.assertFalse(self.browser.find_elements(By.CSS_SELECTOR, ".note-banner"))
# ── T4 ───────────────────────────────────────────────────────────────────
def test_first_valid_save_on_sky_page_fires_banner_and_nvm_dismisses(self):
"""First valid SAVE SKY on /dashboard/sky/ fires the Stargazer banner.
NVM (.btn.btn-danger) dismisses it."""
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.sky_url)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_natus_confirm"))
self.browser.execute_script(_mock_preview_js(_CHART_FIXTURE))
_fill_valid_sky_form(self.browser)
confirm_btn = self.browser.find_element(By.ID, "id_natus_confirm")
self.wait_for(lambda: self.assertIsNone(confirm_btn.get_attribute("disabled")))
confirm_btn.click()
banner = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner")
)
self.assertIn(
"Stargazer",
banner.find_element(By.CSS_SELECTOR, ".note-banner__title").text,
)
banner.find_element(By.CSS_SELECTOR, ".btn.btn-danger").click()
self.wait_for(lambda: self.assertFalse(
self.browser.find_elements(By.CSS_SELECTOR, ".note-banner")
))
# ── T5 ───────────────────────────────────────────────────────────────────
def test_already_earned_note_does_not_show_banner_on_subsequent_save(self):
"""When Stargazer is already in the database for this user, a valid sky
save does not fire another Note banner."""
Note.objects.create(
user=self.gamer,
slug="stargazer",
earned_at=timezone.now(),
)
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.sky_url)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_natus_confirm"))
self.browser.execute_script(_mock_preview_js(_CHART_FIXTURE))
_fill_valid_sky_form(self.browser)
confirm_btn = self.browser.find_element(By.ID, "id_natus_confirm")
self.wait_for(lambda: self.assertIsNone(confirm_btn.get_attribute("disabled")))
confirm_btn.click()
# Wait for save to complete (wheel renders) then assert no banner
self.wait_for(lambda: self.assertTrue(
self.browser.find_elements(By.CSS_SELECTOR, ".nw-root")
))
self.assertFalse(self.browser.find_elements(By.CSS_SELECTOR, ".note-banner"))

View File

@@ -0,0 +1,44 @@
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from .post_page import PostPage
from .my_posts_page import MyPostsPage
class MyPostsTest(FunctionalTest):
def test_logged_in_users_posts_are_saved_as_my_posts(self):
self.create_pre_authenticated_session("disco@test.io")
self.browser.get(self.live_server_url + '/billboard/')
post_page = PostPage(self)
post_page.add_post_line("Reticulate splines")
post_page.add_post_line("Regurgitate spines")
first_post_url = self.browser.current_url
MyPostsPage(self).go_to_my_posts_page("disco@test.io")
self.wait_for(
lambda: self.browser.find_element(By.LINK_TEXT, "Reticulate splines")
)
self.browser.find_element(By.LINK_TEXT, "Reticulate splines").click()
self.wait_for(
lambda: self.assertEqual(self.browser.current_url, first_post_url)
)
self.browser.get(self.live_server_url + '/billboard/')
post_page.add_post_line("Ribbon of death")
second_post_url = self.browser.current_url
MyPostsPage(self).go_to_my_posts_page("disco@test.io")
self.wait_for(
lambda: self.browser.find_element(By.LINK_TEXT, "Ribbon of death")
)
self.browser.find_element(By.CSS_SELECTOR, "#id_logout").click()
self.wait_for(
lambda: self.assertEqual(
self.browser.find_elements(By.LINK_TEXT, "My Posts"),
[],
)
)

View File

@@ -1,80 +0,0 @@
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from .base import FunctionalTest
from .note_page import NotePage
class ItemValidationTest(FunctionalTest):
# Helper functions
def get_error_element(self):
return self.browser.find_element(By.CSS_SELECTOR, ".invalid-feedback")
# Test methods
def test_cannot_add_empty_note_items(self):
self.create_pre_authenticated_session("disco@test.io")
self.browser.get(self.live_server_url)
note_page = NotePage(self)
note_page.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:invalid")
)
note_page.get_item_input_box().send_keys("Purchase milk")
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:valid")
)
note_page.get_item_input_box().send_keys(Keys.ENTER)
note_page.wait_for_row_in_note_table("Purchase milk", 1)
note_page.get_item_input_box().send_keys(Keys.ENTER)
note_page.wait_for_row_in_note_table("Purchase milk", 1)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:invalid")
)
note_page.get_item_input_box().send_keys("Make tea")
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR,
"#id_text:valid",
)
)
note_page.get_item_input_box().send_keys(Keys.ENTER)
note_page.wait_for_row_in_note_table("Make tea", 2)
def test_cannot_add_duplicate_items(self):
self.create_pre_authenticated_session("disco@test.io")
self.browser.get(self.live_server_url)
note_page = NotePage(self)
note_page.add_note_item("Witness divinity")
note_page.get_item_input_box().send_keys("Witness divinity")
note_page.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for(
lambda: self.assertEqual(
self.get_error_element().text,
"You've already logged this to your note",
)
)
def test_error_messages_are_cleared_on_input(self):
self.create_pre_authenticated_session("disco@test.io")
self.browser.get(self.live_server_url)
note_page = NotePage(self)
note_page.add_note_item("Gobbledygook")
note_page.get_item_input_box().send_keys("Gobbledygook")
note_page.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for(
lambda: self.assertTrue(self.get_error_element().is_displayed())
)
note_page.get_item_input_box().send_keys("a")
self.wait_for(
lambda: self.assertFalse(self.get_error_element().is_displayed())
)

View File

@@ -2,61 +2,61 @@ from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from .base import FunctionalTest
from .note_page import NotePage
from .post_page import PostPage
class NewVisitorTest(FunctionalTest):
# Test methods
def test_can_start_a_note(self):
def test_can_start_a_post(self):
self.create_pre_authenticated_session("alice@test.io")
self.browser.get(self.live_server_url)
note_page = NotePage(self)
self.browser.get(self.live_server_url + '/billboard/')
post_page = PostPage(self)
self.assertIn('Earthman RPG', self.browser.title)
header_text = self.browser.find_element(By.TAG_NAME, 'h1').text
self.assertIn('Welcome', header_text)
inputbox = note_page.get_item_input_box()
self.assertEqual(inputbox.get_attribute('placeholder'), 'Enter a note item')
inputbox = post_page.get_line_input_box()
self.assertEqual(inputbox.get_attribute('placeholder'), 'Enter a post line')
inputbox.send_keys('Buy peacock feathers')
inputbox.send_keys(Keys.ENTER)
note_page.wait_for_row_in_note_table("Buy peacock feathers", 1)
post_page.wait_for_row_in_post_table("Buy peacock feathers", 1)
note_page.add_note_item("Use peacock feathers to make a fly")
post_page.add_post_line("Use peacock feathers to make a fly")
note_page.wait_for_row_in_note_table("Use peacock feathers to make a fly", 2)
note_page.wait_for_row_in_note_table("Buy peacock feathers", 1)
post_page.wait_for_row_in_post_table("Use peacock feathers to make a fly", 2)
post_page.wait_for_row_in_post_table("Buy peacock feathers", 1)
def test_multiple_users_can_start_notes_at_different_urls(self):
def test_multiple_users_can_start_posts_at_different_urls(self):
self.create_pre_authenticated_session("alice@test.io")
self.browser.get(self.live_server_url)
note_page = NotePage(self)
note_page.add_note_item("Buy peacock feathers")
self.browser.get(self.live_server_url + '/billboard/')
post_page = PostPage(self)
post_page.add_post_line("Buy peacock feathers")
edith_dash_url = self.browser.current_url
edith_post_url = self.browser.current_url
self.assertRegex(
edith_dash_url,
r'/dashboard/note/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/$',
edith_post_url,
r'/dashboard/post/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/$',
)
self.browser.delete_all_cookies()
self.create_pre_authenticated_session("disco@test.io")
self.browser.get(self.live_server_url)
note_page = NotePage(self)
self.browser.get(self.live_server_url + '/billboard/')
post_page = PostPage(self)
page_text = self.browser.find_element(By.TAG_NAME, 'body').text
self.assertNotIn('Buy peacock feathers', page_text)
note_page.add_note_item("Buy milk")
post_page.add_post_line("Buy milk")
francis_dash_url = self.browser.current_url
francis_post_url = self.browser.current_url
self.assertRegex(
francis_dash_url,
r'/dashboard/note/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/$',
francis_post_url,
r'/dashboard/post/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/$',
)
self.assertNotEqual(francis_dash_url, edith_dash_url)
self.assertNotEqual(francis_post_url, edith_post_url)
page_text = self.browser.find_element(By.TAG_NAME, 'body').text
self.assertNotIn('Buy peacock feathers', page_text)

View File

@@ -0,0 +1,80 @@
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from .base import FunctionalTest
from .post_page import PostPage
class LineValidationTest(FunctionalTest):
# Helper functions
def get_error_element(self):
return self.browser.find_element(By.CSS_SELECTOR, ".invalid-feedback")
# Test methods
def test_cannot_add_empty_post_lines(self):
self.create_pre_authenticated_session("disco@test.io")
self.browser.get(self.live_server_url + '/billboard/')
post_page = PostPage(self)
post_page.get_line_input_box().send_keys(Keys.ENTER)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:invalid")
)
post_page.get_line_input_box().send_keys("Purchase milk")
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:valid")
)
post_page.get_line_input_box().send_keys(Keys.ENTER)
post_page.wait_for_row_in_post_table("Purchase milk", 1)
post_page.get_line_input_box().send_keys(Keys.ENTER)
post_page.wait_for_row_in_post_table("Purchase milk", 1)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:invalid")
)
post_page.get_line_input_box().send_keys("Make tea")
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR,
"#id_text:valid",
)
)
post_page.get_line_input_box().send_keys(Keys.ENTER)
post_page.wait_for_row_in_post_table("Make tea", 2)
def test_cannot_add_duplicate_lines(self):
self.create_pre_authenticated_session("disco@test.io")
self.browser.get(self.live_server_url + '/billboard/')
post_page = PostPage(self)
post_page.add_post_line("Witness divinity")
post_page.get_line_input_box().send_keys("Witness divinity")
post_page.get_line_input_box().send_keys(Keys.ENTER)
self.wait_for(
lambda: self.assertEqual(
self.get_error_element().text,
"You've already logged this to your post",
)
)
def test_error_messages_are_cleared_on_input(self):
self.create_pre_authenticated_session("disco@test.io")
self.browser.get(self.live_server_url + '/billboard/')
post_page = PostPage(self)
post_page.add_post_line("Gobbledygook")
post_page.get_line_input_box().send_keys("Gobbledygook")
post_page.get_line_input_box().send_keys(Keys.ENTER)
self.wait_for(
lambda: self.assertTrue(self.get_error_element().is_displayed())
)
post_page.get_line_input_box().send_keys("a")
self.wait_for(
lambda: self.assertFalse(self.get_error_element().is_displayed())
)

View File

@@ -1,350 +0,0 @@
"""Functional tests for the Recognition system.
Recognition is Earthman's achievement analogue — account-level unlocks earned when
the socius observes a qualifying action. These tests cover the Stargazer Recognition:
earned on the first valid personal sky save (dashboard My Sky applet or sky page),
outside of any game room.
Two test classes — one per surface that can trigger the unlock — each asserting both
the negative (disabled/incomplete save does nothing) and positive (first valid save
fires the banner) conditions.
T2 (Dashboard full flow) is split across three focused tests:
T2a — save → banner → FYI → recognition page item
T2b — palette modal flow on recognition page
T2c — dashboard palette applet reflects Recognition palette unlock
"""
import json as _json
from django.utils import timezone
from selenium.webdriver.common.by import By
from apps.applets.models import Applet
from apps.drama.models import Recognition
from apps.lyric.models import User
from .base import FunctionalTest
# Shared natal chart fixture — same birth data as test_applet_my_sky.py.
_CHART_FIXTURE = {
"planets": {
"Sun": {"sign": "Gemini", "degree": 66.7, "retrograde": False},
"Moon": {"sign": "Taurus", "degree": 43.0, "retrograde": False},
"Mercury": {"sign": "Taurus", "degree": 55.0, "retrograde": False},
"Venus": {"sign": "Gemini", "degree": 63.3, "retrograde": False},
"Mars": {"sign": "Leo", "degree": 132.0, "retrograde": False},
"Jupiter": {"sign": "Capricorn", "degree": 292.0, "retrograde": True},
"Saturn": {"sign": "Virgo", "degree": 153.0, "retrograde": False},
"Uranus": {"sign": "Pisces", "degree": 322.0, "retrograde": False},
"Neptune": {"sign": "Aquarius", "degree": 323.0, "retrograde": True},
"Pluto": {"sign": "Sagittarius", "degree": 269.0, "retrograde": True},
},
"houses": {
"cusps": [180, 210, 240, 270, 300, 330, 0, 30, 60, 90, 120, 150],
"asc": 180.0, "mc": 90.0,
},
"elements": {"Fire": 1, "Water": 0, "Stone": 2, "Air": 4, "Time": 1, "Space": 1},
"aspects": [],
"distinctions": {
"1": 0, "2": 0, "3": 2, "4": 0, "5": 0, "6": 0,
"7": 1, "8": 0, "9": 2, "10": 1, "11": 1, "12": 2,
},
"house_system": "O",
"timezone": "Europe/London",
}
def _mock_preview_js(fixture):
"""Intercepts /sky/preview with a fixture; lets /sky/save reach the real server."""
return f"""
const FIXTURE = {_json.dumps(fixture)};
window._origFetch = window.fetch;
window.fetch = function(url, opts) {{
if (url.includes('/sky/preview')) {{
return Promise.resolve({{
ok: true,
json: () => Promise.resolve(FIXTURE),
}});
}}
return window._origFetch(url, opts);
}};
"""
def _fill_valid_sky_form(browser):
"""Populate form fields and fire input events to trigger schedulePreview."""
browser.execute_script("""
document.getElementById('id_nf_date').value = '1990-06-15';
document.getElementById('id_nf_lat').value = '51.5074';
document.getElementById('id_nf_lon').value = '-0.1278';
document.getElementById('id_nf_tz').value = 'Europe/London';
document.getElementById('id_nf_date').dispatchEvent(
new Event('input', {bubbles: true})
);
""")
# ──────────────────────────────────────────────────────────────────────────────
# Surface A — My Sky applet on the Dashboard
# ──────────────────────────────────────────────────────────────────────────────
class StargazerRecognitionFromDashboardTest(FunctionalTest):
"""Stargazer Recognition triggered from the My Sky applet."""
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
Applet.objects.get_or_create(
slug="my-sky",
defaults={"name": "My Sky", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"},
)
Applet.objects.get_or_create(
slug="palette",
defaults={"name": "Palettes", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"},
)
Applet.objects.get_or_create(
slug="billboard-recognition",
defaults={"name": "Recognition", "grid_cols": 4, "grid_rows": 4, "context": "billboard"},
)
self.gamer = User.objects.create(email="stargazer@test.io")
# ── T1 ───────────────────────────────────────────────────────────────────
def test_disabled_save_button_does_not_unlock_recognition(self):
"""SAVE SKY is disabled before a valid preview fires.
No Recognition banner appears while the button remains disabled."""
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.live_server_url)
confirm_btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_natus_confirm")
)
self.assertIsNotNone(confirm_btn.get_attribute("disabled"))
self.assertFalse(self.browser.find_elements(By.CSS_SELECTOR, ".recog-banner"))
# ── T2a ──────────────────────────────────────────────────────────────────
def test_first_valid_save_from_applet_fires_banner_and_leads_to_recognition_page(self):
"""First valid SAVE SKY from the My Sky applet fires the Stargazer banner.
FYI button navigates to /billboard/recognition/ showing the Stargazer item."""
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.live_server_url)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_applet_sky_form_wrap"))
self.browser.execute_script(_mock_preview_js(_CHART_FIXTURE))
_fill_valid_sky_form(self.browser)
confirm_btn = self.browser.find_element(By.ID, "id_natus_confirm")
self.wait_for(lambda: self.assertIsNone(confirm_btn.get_attribute("disabled")))
confirm_btn.click()
# Banner slides in below the Dash h2
banner = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-banner")
)
self.assertIn(
"Stargazer",
banner.find_element(By.CSS_SELECTOR, ".recog-banner__title").text,
)
banner.find_element(By.CSS_SELECTOR, ".recog-banner__description")
banner.find_element(By.CSS_SELECTOR, ".recog-banner__timestamp")
banner.find_element(By.CSS_SELECTOR, ".recog-banner__image")
banner.find_element(By.CSS_SELECTOR, ".btn.btn-danger") # NVM
fyi = banner.find_element(By.CSS_SELECTOR, ".btn.btn-caution") # FYI
# FYI navigates to Recognition page
fyi.click()
self.wait_for(
lambda: self.assertRegex(self.browser.current_url, r"/billboard/recognition")
)
# Recognition page: one Stargazer item
item = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-list .recog-item")
)
self.assertIn(
"Stargazer",
item.find_element(By.CSS_SELECTOR, ".recog-item__title").text,
)
item.find_element(By.CSS_SELECTOR, ".recog-item__description")
item.find_element(By.CSS_SELECTOR, ".recog-item__image-box")
# ── T2b ──────────────────────────────────────────────────────────────────
def test_recognition_page_palette_modal_flow(self):
"""Recognition page palette modal: image-box opens modal, swatch preview,
body-click restores modal, OK raises confirm submenu, confirm sets palette."""
Recognition.objects.create(
user=self.gamer, slug="stargazer", earned_at=timezone.now(),
)
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.live_server_url + "/billboard/recognition/")
image_box = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-item__image-box")
)
# Clicking ? opens palette modal
image_box.click()
modal = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-palette-modal")
)
modal.find_element(By.CSS_SELECTOR, ".palette-bardo")
modal.find_element(By.CSS_SELECTOR, ".palette-sheol")
# Clicking a swatch body previews the palette and dismisses the modal
bardo_body = modal.find_element(By.CSS_SELECTOR, ".palette-bardo .recog-swatch-body")
self.browser.execute_script(
"arguments[0].dispatchEvent(new MouseEvent('click', {bubbles: true}))",
bardo_body,
)
self.wait_for(lambda: self.assertFalse(
self.browser.find_elements(By.CSS_SELECTOR, ".recog-palette-modal")
))
# Clicking elsewhere ends preview and restores the modal
self.browser.find_element(By.TAG_NAME, "body").click()
modal = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-palette-modal")
)
# Clicking OK on the swatch raises a confirmation submenu
ok_btn = modal.find_element(By.CSS_SELECTOR, ".palette-bardo .btn.btn-confirm")
ok_btn.click()
confirm_menu = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-palette-confirm")
)
# Confirming sets palette, closes modal, replaces image-box with palette swatch
confirm_menu.find_element(By.CSS_SELECTOR, ".btn.btn-confirm").click()
self.wait_for(lambda: self.assertFalse(
self.browser.find_elements(By.CSS_SELECTOR, ".recog-palette-modal")
))
item = self.browser.find_element(By.CSS_SELECTOR, ".recog-item")
self.assertTrue(
item.find_elements(By.CSS_SELECTOR, ".recog-item__palette.palette-bardo")
)
self.assertFalse(
item.find_elements(By.CSS_SELECTOR, ".recog-item__image-box")
)
# ── T2c ──────────────────────────────────────────────────────────────────
def test_dashboard_palette_applet_reflects_recognition_palette_unlock(self):
"""After palette unlock via Recognition, the Dashboard Palette applet shows
the palette swatch as unlocked with Stargazer shoptalk."""
Recognition.objects.create(
user=self.gamer, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.live_server_url)
palette_applet = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_palette")
)
bardo = palette_applet.find_element(By.CSS_SELECTOR, ".swatch.palette-bardo")
self.assertNotIn("locked", bardo.get_attribute("class"))
bardo_ok = bardo.find_element(By.CSS_SELECTOR, ".palette-ok")
self.assertIn("btn-confirm", bardo_ok.get_attribute("class"))
self.assertNotIn("btn-disabled", bardo_ok.get_attribute("class"))
self.assertIn("Stargazer", bardo.get_attribute("data-shoptalk"))
# ──────────────────────────────────────────────────────────────────────────────
# Surface B — /dashboard/sky/ standalone page
# ──────────────────────────────────────────────────────────────────────────────
class StargazerRecognitionFromSkyPageTest(FunctionalTest):
"""Stargazer Recognition triggered from the standalone sky page.
T3 — disabled save on sky page does not fire Recognition.
T4 — first valid save fires banner; NVM dismisses it.
T5 — already-earned Recognition does not re-show banner on subsequent save.
"""
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
Applet.objects.get_or_create(
slug="my-sky",
defaults={"name": "My Sky", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"},
)
Applet.objects.get_or_create(
slug="billboard-recognition",
defaults={"name": "Recognition", "grid_cols": 4, "grid_rows": 4, "context": "billboard"},
)
self.gamer = User.objects.create(email="stargazer@test.io")
self.sky_url = self.live_server_url + "/dashboard/sky/"
# ── T3 ───────────────────────────────────────────────────────────────────
def test_disabled_save_on_sky_page_does_not_unlock_recognition(self):
"""On /dashboard/sky/, SAVE SKY is disabled until preview fires.
No Recognition banner appears while the button is disabled."""
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.sky_url)
confirm_btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_natus_confirm")
)
self.assertIsNotNone(confirm_btn.get_attribute("disabled"))
self.assertFalse(self.browser.find_elements(By.CSS_SELECTOR, ".recog-banner"))
# ── T4 ───────────────────────────────────────────────────────────────────
def test_first_valid_save_on_sky_page_fires_banner_and_nvm_dismisses(self):
"""First valid SAVE SKY on /dashboard/sky/ fires the Stargazer banner.
NVM (.btn.btn-danger) dismisses it."""
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.sky_url)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_natus_confirm"))
self.browser.execute_script(_mock_preview_js(_CHART_FIXTURE))
_fill_valid_sky_form(self.browser)
confirm_btn = self.browser.find_element(By.ID, "id_natus_confirm")
self.wait_for(lambda: self.assertIsNone(confirm_btn.get_attribute("disabled")))
confirm_btn.click()
banner = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-banner")
)
self.assertIn(
"Stargazer",
banner.find_element(By.CSS_SELECTOR, ".recog-banner__title").text,
)
banner.find_element(By.CSS_SELECTOR, ".btn.btn-danger").click()
self.wait_for(lambda: self.assertFalse(
self.browser.find_elements(By.CSS_SELECTOR, ".recog-banner")
))
# ── T5 ───────────────────────────────────────────────────────────────────
def test_already_earned_recognition_does_not_show_banner_on_subsequent_save(self):
"""When Stargazer is already in the database for this user, a valid sky
save does not fire another Recognition banner."""
Recognition.objects.create(
user=self.gamer,
slug="stargazer",
earned_at=timezone.now(),
)
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.sky_url)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_natus_confirm"))
self.browser.execute_script(_mock_preview_js(_CHART_FIXTURE))
_fill_valid_sky_form(self.browser)
confirm_btn = self.browser.find_element(By.ID, "id_natus_confirm")
self.wait_for(lambda: self.assertIsNone(confirm_btn.get_attribute("disabled")))
confirm_btn.click()
# Wait for save to complete (wheel renders) then assert no banner
self.wait_for(lambda: self.assertTrue(
self.browser.find_elements(By.CSS_SELECTOR, ".nw-root")
))
self.assertFalse(self.browser.find_elements(By.CSS_SELECTOR, ".recog-banner"))

View File

@@ -1,143 +1,143 @@
// ── RecognitionSpec.js ────────────────────────────────────────────────────────
// ── NoteSpec.js ───────────────────────────────────────────────────────────────
//
// Unit specs for recognition.js — banner injection from sky/save response.
// Unit specs for note.js — banner injection from sky/save response.
//
// DOM contract assumed by showBanner():
// Any <h2> in the document — banner is inserted immediately after it.
// If absent, banner is prepended to <body>.
//
// API under test:
// Recognition.showBanner(recognition)
// recognition = { slug, title, description, earned_at } → inject .recog-banner
// recognition = null → no-op
// Note.showBanner(note)
// note = { slug, title, description, earned_at } → inject .note-banner
// note = null → no-op
//
// Recognition.handleSaveResponse(data)
// data = { saved: true, recognition: {...} } → delegates to showBanner
// data = { saved: true, recognition: null } → no-op
// Note.handleSaveResponse(data)
// data = { saved: true, note: {...} } → delegates to showBanner
// data = { saved: true, note: null } → no-op
//
// ─────────────────────────────────────────────────────────────────────────────
const SAMPLE_RECOGNITION = {
const SAMPLE_NOTE = {
slug: 'stargazer',
title: 'Stargazer',
description: 'You saved your first personal sky chart.',
earned_at: '2026-04-22T02:00:00+00:00',
};
describe('Recognition.showBanner', () => {
describe('Note.showBanner', () => {
let fixture;
beforeEach(() => {
fixture = document.createElement('div');
fixture.id = 'recognition-fixture';
fixture.id = 'note-fixture';
fixture.innerHTML = '<h2>Dash</h2>';
document.body.appendChild(fixture);
});
afterEach(() => {
document.querySelectorAll('.recog-banner').forEach(b => b.remove());
document.querySelectorAll('.note-banner').forEach(b => b.remove());
fixture.remove();
});
// ── T1 ── null → no banner ────────────────────────────────────────────────
it('T1: showBanner(null) does not inject a banner', () => {
Recognition.showBanner(null);
expect(document.querySelector('.recog-banner')).toBeNull();
Note.showBanner(null);
expect(document.querySelector('.note-banner')).toBeNull();
});
// ── T2 ── recognition present → banner in DOM ─────────────────────────────
// ── T2 ── note present → banner in DOM ───────────────────────────────────
it('T2: showBanner(recognition) injects .recog-banner into the document', () => {
Recognition.showBanner(SAMPLE_RECOGNITION);
expect(document.querySelector('.recog-banner')).not.toBeNull();
it('T2: showBanner(note) injects .note-banner into the document', () => {
Note.showBanner(SAMPLE_NOTE);
expect(document.querySelector('.note-banner')).not.toBeNull();
});
// ── T3 ── title ───────────────────────────────────────────────────────────
it('T3: banner .recog-banner__title contains recognition.title', () => {
Recognition.showBanner(SAMPLE_RECOGNITION);
const el = document.querySelector('.recog-banner__title');
it('T3: banner .note-banner__title contains note.title', () => {
Note.showBanner(SAMPLE_NOTE);
const el = document.querySelector('.note-banner__title');
expect(el).not.toBeNull();
expect(el.textContent).toContain('Stargazer');
});
// ── T4 ── description ─────────────────────────────────────────────────────
it('T4: banner .recog-banner__description contains recognition.description', () => {
Recognition.showBanner(SAMPLE_RECOGNITION);
const el = document.querySelector('.recog-banner__description');
it('T4: banner .note-banner__description contains note.description', () => {
Note.showBanner(SAMPLE_NOTE);
const el = document.querySelector('.note-banner__description');
expect(el).not.toBeNull();
expect(el.textContent).toContain('You saved your first personal sky chart.');
});
// ── T5 ── timestamp ───────────────────────────────────────────────────────
it('T5: banner has a .recog-banner__timestamp element', () => {
Recognition.showBanner(SAMPLE_RECOGNITION);
expect(document.querySelector('.recog-banner__timestamp')).not.toBeNull();
it('T5: banner has a .note-banner__timestamp element', () => {
Note.showBanner(SAMPLE_NOTE);
expect(document.querySelector('.note-banner__timestamp')).not.toBeNull();
});
// ── T6 ── image area ──────────────────────────────────────────────────────
it('T6: banner has a .recog-banner__image element', () => {
Recognition.showBanner(SAMPLE_RECOGNITION);
expect(document.querySelector('.recog-banner__image')).not.toBeNull();
it('T6: banner has a .note-banner__image element', () => {
Note.showBanner(SAMPLE_NOTE);
expect(document.querySelector('.note-banner__image')).not.toBeNull();
});
// ── T7 ── NVM button ──────────────────────────────────────────────────────
it('T7: banner has a .btn.btn-danger NVM button', () => {
Recognition.showBanner(SAMPLE_RECOGNITION);
expect(document.querySelector('.recog-banner .btn.btn-danger')).not.toBeNull();
Note.showBanner(SAMPLE_NOTE);
expect(document.querySelector('.note-banner .btn.btn-danger')).not.toBeNull();
});
// ── T8 ── FYI link ────────────────────────────────────────────────────────
it('T8: banner has a .btn.btn-caution FYI link pointing to /billboard/recognition/', () => {
Recognition.showBanner(SAMPLE_RECOGNITION);
const fyi = document.querySelector('.recog-banner .btn.btn-caution');
it('T8: banner has a .btn.btn-caution FYI link pointing to /billboard/my-notes/', () => {
Note.showBanner(SAMPLE_NOTE);
const fyi = document.querySelector('.note-banner .btn.btn-caution');
expect(fyi).not.toBeNull();
expect(fyi.getAttribute('href')).toBe('/billboard/recognition/');
expect(fyi.getAttribute('href')).toBe('/billboard/my-notes/');
});
// ── T9 ── NVM dismissal ───────────────────────────────────────────────────
it('T9: clicking the NVM button removes the banner from the DOM', () => {
Recognition.showBanner(SAMPLE_RECOGNITION);
document.querySelector('.recog-banner .btn.btn-danger').click();
expect(document.querySelector('.recog-banner')).toBeNull();
Note.showBanner(SAMPLE_NOTE);
document.querySelector('.note-banner .btn.btn-danger').click();
expect(document.querySelector('.note-banner')).toBeNull();
});
// ── T10 ── placement after h2 ─────────────────────────────────────────────
it('T10: banner is inserted immediately after the first h2 in the document', () => {
Recognition.showBanner(SAMPLE_RECOGNITION);
Note.showBanner(SAMPLE_NOTE);
const h2 = fixture.querySelector('h2');
expect(h2.nextElementSibling.classList.contains('recog-banner')).toBeTrue();
expect(h2.nextElementSibling.classList.contains('note-banner')).toBeTrue();
});
});
describe('Recognition.handleSaveResponse', () => {
describe('Note.handleSaveResponse', () => {
afterEach(() => {
document.querySelectorAll('.recog-banner').forEach(b => b.remove());
document.querySelectorAll('.note-banner').forEach(b => b.remove());
});
// ── T11 ── delegates when recognition present ─────────────────────────────
// ── T11 ── delegates when note present ────────────────────────────────────
it('T11: handleSaveResponse shows banner when data.recognition is present', () => {
Recognition.handleSaveResponse({ saved: true, recognition: SAMPLE_RECOGNITION });
expect(document.querySelector('.recog-banner')).not.toBeNull();
it('T11: handleSaveResponse shows banner when data.note is present', () => {
Note.handleSaveResponse({ saved: true, note: SAMPLE_NOTE });
expect(document.querySelector('.note-banner')).not.toBeNull();
});
// ── T12 ── no banner when recognition null ────────────────────────────────
// ── T12 ── no banner when note null ───────────────────────────────────────
it('T12: handleSaveResponse does not show banner when data.recognition is null', () => {
Recognition.handleSaveResponse({ saved: true, recognition: null });
expect(document.querySelector('.recog-banner')).toBeNull();
it('T12: handleSaveResponse does not show banner when data.note is null', () => {
Note.handleSaveResponse({ saved: true, note: null });
expect(document.querySelector('.note-banner')).toBeNull();
});
});

View File

@@ -23,10 +23,10 @@
<script src="TraySpec.js"></script>
<script src="SigSelectSpec.js"></script>
<script src="NatusWheelSpec.js"></script>
<script src="RecognitionSpec.js"></script>
<script src="NoteSpec.js"></script>
<!-- src files -->
<script src="/static/apps/dashboard/dashboard.js"></script>
<script src="/static/apps/dashboard/recognition.js"></script>
<script src="/static/apps/dashboard/note.js"></script>
<script src="/static/apps/epic/role-select.js"></script>
<script src="/static/apps/epic/tray.js"></script>
<script src="/static/apps/epic/sig-select.js"></script>

View File

@@ -1,3 +1,28 @@
// ── My Posts applet ────────────────────────────────────────────────────────
#id_applet_my_posts {
display: flex;
flex-direction: column;
.my-posts-container {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
mask-origin: padding-box;
mask-clip: padding-box;
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 5%,
black 85%,
transparent 100%
);
}
}
// ── Shared aperture fill for both billboard pages ──────────────────────────
%billboard-page-base {
@@ -87,20 +112,20 @@ body.page-billscroll {
}
// ── Billboard applet placement ─────────────────────────────────────────────
// Left column (4-wide): My Scrolls → Contacts → Recognition stacked.
// Left column (4-wide): My Scrolls → Contacts → Notes stacked.
// Right column (8-wide): Most Recent spans full height.
// Portrait override (container query) restores stacked full-width layout.
#id_billboard_applets_container {
#id_applet_billboard_my_scrolls { grid-column: 1 / span 4; grid-row: 1 / span 3; }
#id_applet_billboard_my_contacts { grid-column: 1 / span 4; grid-row: 4 / span 3; }
#id_applet_billboard_recognition { grid-column: 1 / span 4; grid-row: 7 / span 4; }
#id_applet_billboard_notes { grid-column: 1 / span 4; grid-row: 7 / span 4; }
#id_applet_billboard_most_recent { grid-column: 5 / span 8; grid-row: 1 / span 10; }
@container (max-width: 550px) {
#id_applet_billboard_my_scrolls,
#id_applet_billboard_my_contacts,
#id_applet_billboard_recognition,
#id_applet_billboard_notes,
#id_applet_billboard_most_recent {
grid-column: 1 / span 12;
grid-row: span var(--applet-rows, 3);
@@ -108,9 +133,9 @@ body.page-billscroll {
}
}
// ── Recognition applet — vertical title ───────────────────────────────────
// ── Notes applet — vertical title ─────────────────────────────────────────
#id_applet_billboard_recognition {
#id_applet_billboard_notes {
h2 {
writing-mode: vertical-rl;
transform: rotate(180deg);

View File

@@ -30,31 +30,6 @@ body.page-dashboard {
}
#id_applets_container {
#id_applet_my_notes {
display: flex;
flex-direction: column;
.my-notes-container {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
mask-origin: padding-box;
mask-clip: padding-box;
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 5%,
black 85%,
transparent 100%
);
}
}
#id_applet_wallet {
display: flex;
flex-direction: column;

View File

@@ -1,6 +1,6 @@
// Recognition banner (slides in below page h2 after unlock)
// Note banner (slides in below page h2 after unlock)
.recog-banner {
.note-banner {
display: flex;
align-items: center;
gap: 0.75rem;
@@ -9,24 +9,24 @@
border-left: 3px solid rgba(var(--priUser), 0.6);
margin-bottom: 0.75rem;
.recog-banner__body {
.note-banner__body {
flex: 1;
min-width: 0;
}
.recog-banner__title {
.note-banner__title {
margin: 0;
font-weight: bold;
}
.recog-banner__description,
.recog-banner__timestamp {
.note-banner__description,
.note-banner__timestamp {
margin: 0.1rem 0 0;
font-size: 0.85rem;
opacity: 0.75;
}
.recog-banner__image {
.note-banner__image {
width: 3rem;
height: 3rem;
flex-shrink: 0;
@@ -34,21 +34,21 @@
border-radius: 2px;
}
.recog-banner__nvm,
.recog-banner__fyi {
.note-banner__nvm,
.note-banner__fyi {
flex-shrink: 0;
font-size: 0.8rem;
padding: 0.25rem 0.6rem;
}
}
// Recognition page
// Notes page
.recognition-page {
.note-page {
padding: 0.75rem;
}
.recog-list {
.note-list {
list-style: none;
padding: 0;
margin: 0;
@@ -57,7 +57,7 @@
gap: 1rem;
}
.recog-item {
.note-item {
position: relative;
display: flex;
flex-direction: column;
@@ -68,12 +68,12 @@
border-radius: 4px;
width: 14rem;
.recog-item__title {
.note-item__title {
margin: 0;
font-weight: bold;
}
.recog-item__description {
.note-item__description {
margin: 0;
font-size: 0.85rem;
opacity: 0.75;
@@ -81,7 +81,7 @@
}
// Image box must have a defined size so Selenium can interact with it.
.recog-item__image-box {
.note-item__image-box {
width: 5rem;
height: 5rem;
display: flex;
@@ -97,8 +97,8 @@
&:hover { opacity: 1; }
}
// Unlocked palette swatch inside a recognition item
.recog-item__palette {
// Unlocked palette swatch inside a note item
.note-item__palette {
width: 5rem;
height: 5rem;
border-radius: 2px;
@@ -107,7 +107,7 @@
// Palette modal
.recog-palette-modal {
.note-palette-modal {
position: absolute;
top: 0;
left: 0;
@@ -127,7 +127,7 @@
align-items: center;
gap: 0.5rem;
.recog-swatch-body {
.note-swatch-body {
width: 2.5rem;
height: 2.5rem;
border-radius: 2px;
@@ -137,31 +137,24 @@
&:hover { border-color: rgba(var(--priUser), 0.8); }
}
.btn {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
}
}
}
// Palette swatch color fills
// These match the actual palette CSS variables used both in modal swatches
// and as the confirmed .recog-item__palette swatch.
.palette-bardo .recog-swatch-body,
.recog-item__palette.palette-bardo {
.palette-bardo .note-swatch-body,
.note-item__palette.palette-bardo {
background: #2a1a2e;
}
.palette-sheol .recog-swatch-body,
.recog-item__palette.palette-sheol {
.palette-sheol .note-swatch-body,
.note-item__palette.palette-sheol {
background: #1a1a2e;
}
// Confirm submenu
.recog-palette-confirm {
.note-palette-confirm {
border-top: 1px solid rgba(var(--priUser), 0.2);
padding-top: 0.5rem;
gap: 0.4rem;
@@ -172,9 +165,4 @@
margin: 0;
font-size: 0.85rem;
}
.btn {
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
}
}

View File

@@ -10,7 +10,7 @@
@import 'natus';
@import 'tray';
@import 'billboard';
@import 'recognition';
@import 'note';
@import 'tooltips';
@import 'game-kit';
@import 'wallet-tokens';

View File

@@ -1,143 +1,143 @@
// ── RecognitionSpec.js ────────────────────────────────────────────────────────
// ── NoteSpec.js ───────────────────────────────────────────────────────────────
//
// Unit specs for recognition.js — banner injection from sky/save response.
// Unit specs for note.js — banner injection from sky/save response.
//
// DOM contract assumed by showBanner():
// Any <h2> in the document — banner is inserted immediately after it.
// If absent, banner is prepended to <body>.
//
// API under test:
// Recognition.showBanner(recognition)
// recognition = { slug, title, description, earned_at } → inject .recog-banner
// recognition = null → no-op
// Note.showBanner(note)
// note = { slug, title, description, earned_at } → inject .note-banner
// note = null → no-op
//
// Recognition.handleSaveResponse(data)
// data = { saved: true, recognition: {...} } → delegates to showBanner
// data = { saved: true, recognition: null } → no-op
// Note.handleSaveResponse(data)
// data = { saved: true, note: {...} } → delegates to showBanner
// data = { saved: true, note: null } → no-op
//
// ─────────────────────────────────────────────────────────────────────────────
const SAMPLE_RECOGNITION = {
const SAMPLE_NOTE = {
slug: 'stargazer',
title: 'Stargazer',
description: 'You saved your first personal sky chart.',
earned_at: '2026-04-22T02:00:00+00:00',
};
describe('Recognition.showBanner', () => {
describe('Note.showBanner', () => {
let fixture;
beforeEach(() => {
fixture = document.createElement('div');
fixture.id = 'recognition-fixture';
fixture.id = 'note-fixture';
fixture.innerHTML = '<h2>Dash</h2>';
document.body.appendChild(fixture);
});
afterEach(() => {
document.querySelectorAll('.recog-banner').forEach(b => b.remove());
document.querySelectorAll('.note-banner').forEach(b => b.remove());
fixture.remove();
});
// ── T1 ── null → no banner ────────────────────────────────────────────────
it('T1: showBanner(null) does not inject a banner', () => {
Recognition.showBanner(null);
expect(document.querySelector('.recog-banner')).toBeNull();
Note.showBanner(null);
expect(document.querySelector('.note-banner')).toBeNull();
});
// ── T2 ── recognition present → banner in DOM ─────────────────────────────
// ── T2 ── note present → banner in DOM ───────────────────────────────────
it('T2: showBanner(recognition) injects .recog-banner into the document', () => {
Recognition.showBanner(SAMPLE_RECOGNITION);
expect(document.querySelector('.recog-banner')).not.toBeNull();
it('T2: showBanner(note) injects .note-banner into the document', () => {
Note.showBanner(SAMPLE_NOTE);
expect(document.querySelector('.note-banner')).not.toBeNull();
});
// ── T3 ── title ───────────────────────────────────────────────────────────
it('T3: banner .recog-banner__title contains recognition.title', () => {
Recognition.showBanner(SAMPLE_RECOGNITION);
const el = document.querySelector('.recog-banner__title');
it('T3: banner .note-banner__title contains note.title', () => {
Note.showBanner(SAMPLE_NOTE);
const el = document.querySelector('.note-banner__title');
expect(el).not.toBeNull();
expect(el.textContent).toContain('Stargazer');
});
// ── T4 ── description ─────────────────────────────────────────────────────
it('T4: banner .recog-banner__description contains recognition.description', () => {
Recognition.showBanner(SAMPLE_RECOGNITION);
const el = document.querySelector('.recog-banner__description');
it('T4: banner .note-banner__description contains note.description', () => {
Note.showBanner(SAMPLE_NOTE);
const el = document.querySelector('.note-banner__description');
expect(el).not.toBeNull();
expect(el.textContent).toContain('You saved your first personal sky chart.');
});
// ── T5 ── timestamp ───────────────────────────────────────────────────────
it('T5: banner has a .recog-banner__timestamp element', () => {
Recognition.showBanner(SAMPLE_RECOGNITION);
expect(document.querySelector('.recog-banner__timestamp')).not.toBeNull();
it('T5: banner has a .note-banner__timestamp element', () => {
Note.showBanner(SAMPLE_NOTE);
expect(document.querySelector('.note-banner__timestamp')).not.toBeNull();
});
// ── T6 ── image area ──────────────────────────────────────────────────────
it('T6: banner has a .recog-banner__image element', () => {
Recognition.showBanner(SAMPLE_RECOGNITION);
expect(document.querySelector('.recog-banner__image')).not.toBeNull();
it('T6: banner has a .note-banner__image element', () => {
Note.showBanner(SAMPLE_NOTE);
expect(document.querySelector('.note-banner__image')).not.toBeNull();
});
// ── T7 ── NVM button ──────────────────────────────────────────────────────
it('T7: banner has a .btn.btn-danger NVM button', () => {
Recognition.showBanner(SAMPLE_RECOGNITION);
expect(document.querySelector('.recog-banner .btn.btn-danger')).not.toBeNull();
Note.showBanner(SAMPLE_NOTE);
expect(document.querySelector('.note-banner .btn.btn-danger')).not.toBeNull();
});
// ── T8 ── FYI link ────────────────────────────────────────────────────────
it('T8: banner has a .btn.btn-caution FYI link pointing to /billboard/recognition/', () => {
Recognition.showBanner(SAMPLE_RECOGNITION);
const fyi = document.querySelector('.recog-banner .btn.btn-caution');
it('T8: banner has a .btn.btn-caution FYI link pointing to /billboard/my-notes/', () => {
Note.showBanner(SAMPLE_NOTE);
const fyi = document.querySelector('.note-banner .btn.btn-caution');
expect(fyi).not.toBeNull();
expect(fyi.getAttribute('href')).toBe('/billboard/recognition/');
expect(fyi.getAttribute('href')).toBe('/billboard/my-notes/');
});
// ── T9 ── NVM dismissal ───────────────────────────────────────────────────
it('T9: clicking the NVM button removes the banner from the DOM', () => {
Recognition.showBanner(SAMPLE_RECOGNITION);
document.querySelector('.recog-banner .btn.btn-danger').click();
expect(document.querySelector('.recog-banner')).toBeNull();
Note.showBanner(SAMPLE_NOTE);
document.querySelector('.note-banner .btn.btn-danger').click();
expect(document.querySelector('.note-banner')).toBeNull();
});
// ── T10 ── placement after h2 ─────────────────────────────────────────────
it('T10: banner is inserted immediately after the first h2 in the document', () => {
Recognition.showBanner(SAMPLE_RECOGNITION);
Note.showBanner(SAMPLE_NOTE);
const h2 = fixture.querySelector('h2');
expect(h2.nextElementSibling.classList.contains('recog-banner')).toBeTrue();
expect(h2.nextElementSibling.classList.contains('note-banner')).toBeTrue();
});
});
describe('Recognition.handleSaveResponse', () => {
describe('Note.handleSaveResponse', () => {
afterEach(() => {
document.querySelectorAll('.recog-banner').forEach(b => b.remove());
document.querySelectorAll('.note-banner').forEach(b => b.remove());
});
// ── T11 ── delegates when recognition present ─────────────────────────────
// ── T11 ── delegates when note present ────────────────────────────────────
it('T11: handleSaveResponse shows banner when data.recognition is present', () => {
Recognition.handleSaveResponse({ saved: true, recognition: SAMPLE_RECOGNITION });
expect(document.querySelector('.recog-banner')).not.toBeNull();
it('T11: handleSaveResponse shows banner when data.note is present', () => {
Note.handleSaveResponse({ saved: true, note: SAMPLE_NOTE });
expect(document.querySelector('.note-banner')).not.toBeNull();
});
// ── T12 ── no banner when recognition null ────────────────────────────────
// ── T12 ── no banner when note null ───────────────────────────────────────
it('T12: handleSaveResponse does not show banner when data.recognition is null', () => {
Recognition.handleSaveResponse({ saved: true, recognition: null });
expect(document.querySelector('.recog-banner')).toBeNull();
it('T12: handleSaveResponse does not show banner when data.note is null', () => {
Note.handleSaveResponse({ saved: true, note: null });
expect(document.querySelector('.note-banner')).toBeNull();
});
});

View File

@@ -1,6 +1,6 @@
<section
id="id_applet_billboard_recognition"
id="id_applet_billboard_notes"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2><a href="/billboard/recognition/">Recognition</a></h2>
<h2><a href="/billboard/my-notes/">My Notes</a></h2>
</section>

View File

@@ -0,0 +1,17 @@
<section
id="id_applet_my_posts"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2><a href="{% url 'my_posts' user.id %}" class="my-posts-main">My Posts</a></h2>
<div class="my-posts-container">
<ul>
{% for post in recent_posts %}
<li>
<a href="{{ post.get_absolute_url }}">{{ post.name }}</a>
</li>
{% empty %}
<li>No posts yet.</li>
{% endfor %}
</ul>
</div>
</section>

View File

@@ -1,8 +1,8 @@
<section
id="id_applet_new_note"
id="id_applet_new_post"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2>New Note</h2>
{% url "new_note" as form_action %}
<h2>New Post</h2>
{% url "new_post" as form_action %}
{% include "apps/dashboard/_partials/_form.html" with form=form form_action=form_action %}
</section>

View File

@@ -0,0 +1,49 @@
{% extends "core/base.html" %}
{% load static %}
{% block title_text %}Billnotes{% endblock title_text %}
{% block header_text %}<span>Bill</span>notes{% endblock header_text %}
{% block content %}
<div class="note-page">
<h2>My Notes</h2>
<ul class="note-list">
{% for item in note_items %}
<li class="note-item" data-slug="{{ item.obj.slug }}"
data-set-palette-url="{% url 'billboard:note_set_palette' item.obj.slug %}">
{% if item.obj.palette %}
<div class="note-item__palette {{ item.obj.palette }}"></div>
{% else %}
<div class="note-item__image-box">?</div>
{% endif %}
<p class="note-item__title">{{ item.title }}</p>
<p class="note-item__description">{{ item.description }}</p>
{% if not item.obj.palette and item.palette_options %}
<template class="note-palette-modal-tpl">
<div class="note-palette-modal">
{% for palette_name in item.palette_options %}
<div class="{{ palette_name }}">
<div class="note-swatch-body"></div>
<button type="button" class="btn btn-confirm">OK</button>
</div>
{% endfor %}
<div class="note-palette-confirm" hidden>
<p>Lock in this palette?</p>
<button type="button" class="btn btn-confirm">OK</button>
<button type="button" class="btn btn-cancel">NVM</button>
</div>
</div>
</template>
{% endif %}
</li>
{% empty %}
<li class="note-item note-item--empty">No notes yet.</li>
{% endfor %}
</ul>
</div>
<script src="{% static 'apps/billboard/note-page.js' %}"></script>
{% endblock %}

View File

@@ -1,49 +0,0 @@
{% extends "core/base.html" %}
{% load static %}
{% block title_text %}Recognition{% endblock title_text %}
{% block header_text %}<span>Bill</span>recognition{% endblock header_text %}
{% block content %}
<div class="recognition-page">
<h2>Recognition</h2>
<ul class="recog-list">
{% for item in recognition_items %}
<li class="recog-item" data-slug="{{ item.obj.slug }}"
data-set-palette-url="{% url 'billboard:recognition_set_palette' item.obj.slug %}">
{% if item.obj.palette %}
<div class="recog-item__palette {{ item.obj.palette }}"></div>
{% else %}
<div class="recog-item__image-box">?</div>
{% endif %}
<p class="recog-item__title">{{ item.title }}</p>
<p class="recog-item__description">{{ item.description }}</p>
{% if not item.obj.palette and item.palette_options %}
<template class="recog-palette-modal-tpl">
<div class="recog-palette-modal">
{% for palette_name in item.palette_options %}
<div class="{{ palette_name }}">
<div class="recog-swatch-body"></div>
<button type="button" class="btn btn-confirm">OK</button>
</div>
{% endfor %}
<div class="recog-palette-confirm" hidden>
<p>Lock in this palette?</p>
<button type="button" class="btn btn-confirm">OK</button>
<button type="button" class="btn btn-cancel">NVM</button>
</div>
</div>
</template>
{% endif %}
</li>
{% empty %}
<li class="recog-item recog-item--empty">No recognitions yet.</li>
{% endfor %}
</ul>
</div>
<script src="{% static 'apps/billboard/recognition-page.js' %}"></script>
{% endblock %}

View File

@@ -1,17 +0,0 @@
<section
id="id_applet_my_notes"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2><a href="{% url 'my_notes' user.id %}" class="my-notes-main">My Notes</a></h2>
<div class="my-notes-container">
<ul>
{% for note in recent_notes %}
<li>
<a href="{{ note.get_absolute_url }}">{{ note.name }}</a>
</li>
{% empty %}
<li>No notes yet.</li>
{% endfor %}
</ul>
</div>
</section>

View File

@@ -4,7 +4,7 @@
id="id_text"
name="text"
class="form-control form-control-lg{% if form.errors.text %} is-invalid{% endif %}"
placeholder="Enter a note item"
placeholder="Enter a post line"
value="{{ form.text.value | default:'' }}"
aria-describedby="id_text_feedback"
required

View File

@@ -1,20 +0,0 @@
{% extends "core/base.html" %}
{% load lyric_extras %}
{% block title_text %}Dashnotes{% endblock title_text %}
{% block header_text %}<span>Dash</span>notes{% endblock header_text %}
{% block content %}
<h3>{{ owner|display_name }}'s notes</h3>
<ul>
{% for note in owner.notes.all %}
<li><a href="{{ note.get_absolute_url }}">{{ note.name }}</a></li>
{% endfor %}
</ul>
<h3>Notes shared with me</h3>
<ul>
{% for note in owner.shared_notes.all %}
<li><a href="{{ note.get_absolute_url }}">{{ note.name }}</a></li>
{% endfor %}
</ul>
{% endblock content %}

View File

@@ -0,0 +1,20 @@
{% extends "core/base.html" %}
{% load lyric_extras %}
{% block title_text %}Dashposts{% endblock title_text %}
{% block header_text %}<span>Dash</span>posts{% endblock header_text %}
{% block content %}
<h3>{{ owner|display_name }}'s posts</h3>
<ul>
{% for post in owner.posts.all %}
<li><a href="{{ post.get_absolute_url }}">{{ post.name }}</a></li>
{% endfor %}
</ul>
<h3>Posts shared with me</h3>
<ul>
{% for post in owner.shared_posts.all %}
<li><a href="{{ post.get_absolute_url }}">{{ post.name }}</a></li>
{% endfor %}
</ul>
{% endblock content %}

View File

@@ -1,22 +1,22 @@
{% extends "core/base.html" %}
{% load lyric_extras %}
{% block title_text %}Dashnote{% endblock title_text %}
{% block header_text %}<span>Dash</span>note{% endblock header_text %}
{% block title_text %}Dashpost{% endblock title_text %}
{% block header_text %}<span>Dash</span>post{% endblock header_text %}
{% block extra_header %}
{% url "view_note" note.id as form_action %}
{% url "view_post" post.id as form_action %}
{% include "apps/dashboard/_partials/_form.html" with form=form form_action=form_action %}
{% endblock extra_header %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
<small>Note created by: <span id="id_note_owner">{{ note.owner|display_name }}</span></small>
<table id="id_note_table" class="table">
{% for item in note.item_set.all %}
<tr><td>{{ forloop.counter }}. {{ item.text }}</td></tr>
<small>Post created by: <span id="id_post_owner">{{ post.owner|display_name }}</span></small>
<table id="id_post_table" class="table">
{% for line in post.lines.all %}
<tr><td>{{ forloop.counter }}. {{ line.text }}</td></tr>
{% endfor %}
</table>
</div>
@@ -25,7 +25,7 @@
<div class="row justify-content-center">
<div class="col-lg-6">
<form method="POST" action="{% url "share_note" note.id %}">
<form method="POST" action="{% url "share_post" post.id %}">
{% csrf_token %}
<input
id="id_recipient"
@@ -43,9 +43,9 @@
<button type="submit" class="btn btn-primary">Share</button>
</form>
<small>Note shared with:
{% for user in note.shared_with.all %}
<span class="note-recipient">{{ user|display_name }}</span>
<small>Post shared with:
{% for user in post.shared_with.all %}
<span class="post-recipient">{{ user|display_name }}</span>
{% endfor %}
</small>
</div>

View File

@@ -93,7 +93,7 @@
<script src="{% static 'apps/gameboard/d3.min.js' %}"></script>
<script src="{% static 'apps/gameboard/natus-wheel.js' %}"></script>
<script src="{% static 'apps/dashboard/recognition.js' %}"></script>
<script src="{% static 'apps/dashboard/note.js' %}"></script>
<script>
(function () {
'use strict';