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:
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
18
src/apps/dashboard/migrations/0004_rename_note_to_post.py
Normal file
18
src/apps/dashboard/migrations/0004_rename_note_to_post.py
Normal 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')},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
49
src/apps/dashboard/static/apps/dashboard/note.js
Normal file
49
src/apps/dashboard/static/apps/dashboard/note.js
Normal 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 };
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
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",
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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="/")
|
||||
|
||||
24
src/apps/drama/migrations/0005_rename_recognition_to_note.py
Normal file
24
src/apps/drama/migrations/0005_rename_recognition_to_note.py
Normal 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',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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(
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
|
||||
44
src/functional_tests/test_applet_my_posts.py
Normal file
44
src/functional_tests/test_applet_my_posts.py
Normal 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"),
|
||||
[],
|
||||
)
|
||||
)
|
||||
@@ -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())
|
||||
)
|
||||
@@ -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)
|
||||
80
src/functional_tests/test_applet_new_post_line_validation.py
Normal file
80
src/functional_tests/test_applet_new_post_line_validation.py
Normal 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())
|
||||
)
|
||||
@@ -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"))
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
@import 'natus';
|
||||
@import 'tray';
|
||||
@import 'billboard';
|
||||
@import 'recognition';
|
||||
@import 'note';
|
||||
@import 'tooltips';
|
||||
@import 'game-kit';
|
||||
@import 'wallet-tokens';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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>
|
||||
17
src/templates/apps/billboard/_partials/_applet-my-posts.html
Normal file
17
src/templates/apps/billboard/_partials/_applet-my-posts.html
Normal 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>
|
||||
@@ -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>
|
||||
49
src/templates/apps/billboard/my_notes.html
Normal file
49
src/templates/apps/billboard/my_notes.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
20
src/templates/apps/dashboard/my_posts.html
Normal file
20
src/templates/apps/dashboard/my_posts.html
Normal 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 %}
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user