brief sprint C3.a: Note unlock spawns Line + Brief on the user's per-category Post; banner JS consumes the new brief payload (FYI → post detail, square → my-notes) — TDD
Wires the C2 Brief model into the existing Note-unlock pipeline. Per the per-category Post model: each user has a single Post(owner=user, kind=NOTE_UNLOCK) titled by the header Line "Look! — new Note unlocked"; each unlock appends a per-event Line ("Stargazer, 5:21:00 PM") + spawns a Brief FK'd to that Line.
Server:
- billboard.Post gains a `kind` enum (NOTE_UNLOCK / USER_POST / SHARE_INVITE, default USER_POST) so the per-category Post is deterministically discoverable via (owner, kind=NOTE_UNLOCK).
- drama.Note.grant_if_new now returns (note, created, brief) — backwards-incompat tuple shape; the new third slot is None on idempotent re-grants. On a fresh grant it: get-or-creates the user's Note Unlocks Post; ensures the header Line; appends a per-event Line w. timestamp; creates a Brief(kind=NOTE_UNLOCK, owner, post, line, title=note.display_title).
- dashboard.views.sky_save's response shape flips {note: {...}|null} → {brief: {...}|null}. The new helper _brief_to_banner_dict exposes {id, kind, title, line_text, post_url, square_url, created_at}; for NOTE_UNLOCK kind, square_url = reverse('billboard:my_notes').
Banner JS (apps/dashboard/note.js):
- Module renamed Note → Brief (Note kept as an alias during the C3 sprint; will retire in C3.e). showBanner now consumes the Brief shape: title from brief.title, body from brief.line_text, time from brief.created_at, FYI href = brief.post_url, NVM dismisses, .note-banner__image is rendered as a clickable <a href=brief.square_url> when square_url is set. The CSS class names (.note-banner, .note-banner__title, etc.) stay so all existing SCSS + FT selectors keep working.
- sky.html + _applet-my-sky.html flip Note.handleSaveResponse → Brief.handleSaveResponse.
Tests:
- new IT class GrantIfNewSpawnsBriefTest (5 tests): first grant creates Post+Line+Brief, idempotent on second grant returns no brief, two different slugs share one Post, line text carries note title, post.kind == NOTE_UNLOCK. PostKindFieldTest (2 tests): default user_post + all three choices present.
- existing drama test_models.GrantIfNew tests updated to unpack the third tuple element.
- dashboard.tests.integrated.test_sky_views: 5 tests flipped from data["note"] → data["brief"] w/ the new payload shape (kind=note_unlock, title=Stargazer, line_text contains Stargazer, post_url under /billboard/post/, square_url=/billboard/my-notes/).
- NoteSpec.js (Jasmine): 12 specs rewritten for the Brief shape — SAMPLE_BRIEF carries the seven fields, T6 asserts the square is a clickable <a>, T8 asserts FYI points at brief.post_url (was hardcoded /billboard/my-notes/).
- functional_tests.test_applet_my_notes: T2 split — FYI now navigates to /billboard/post/<uuid>/ (the post detail / mark-read contract), the .note-banner__image square preserves the legacy jump direct to /billboard/my-notes/.
billboard/0003_post_kind migration auto-generated. 808 ITs + 9 my_notes FTs + 1 Jasmine FT all green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -308,14 +308,14 @@ class NoteModelTest(TestCase):
|
||||
self.assertIn("earner@test.io", s)
|
||||
|
||||
def test_grant_if_new_creates_on_first_call(self):
|
||||
recog, created = Note.grant_if_new(self.user, "stargazer")
|
||||
recog, created, _brief = 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):
|
||||
Note.grant_if_new(self.user, "stargazer")
|
||||
recog, created = Note.grant_if_new(self.user, "stargazer")
|
||||
recog, created, _brief = Note.grant_if_new(self.user, "stargazer")
|
||||
self.assertFalse(created)
|
||||
self.assertEqual(Note.objects.count(), 1)
|
||||
|
||||
@@ -324,6 +324,6 @@ class NoteModelTest(TestCase):
|
||||
user=self.user, slug="stargazer",
|
||||
earned_at=timezone.now(), palette="palette-bardo",
|
||||
)
|
||||
recog, created = Note.grant_if_new(self.user, "stargazer")
|
||||
recog, created, _brief = Note.grant_if_new(self.user, "stargazer")
|
||||
self.assertFalse(created)
|
||||
self.assertEqual(recog.palette, "palette-bardo")
|
||||
|
||||
85
src/apps/drama/tests/integrated/test_note_brief.py
Normal file
85
src/apps/drama/tests/integrated/test_note_brief.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""ITs for the Brief sprint C3.a — Note.grant_if_new spawns Line + Brief.
|
||||
|
||||
Per the per-category Post model: the user has a single "Note Unlocks" Post;
|
||||
each unlock appends a Line ("Stargazer, 5:21pm") and spawns a Brief FKing
|
||||
the appended Line. Briefs of kind=NOTE_UNLOCK live on Posts of kind=
|
||||
NOTE_UNLOCK.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.billboard.models import Brief, Line, Post
|
||||
from apps.drama.models import Note
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class GrantIfNewSpawnsBriefTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="brief-grant@test.io")
|
||||
|
||||
def test_first_grant_creates_post_line_and_brief(self):
|
||||
note, created, brief = Note.grant_if_new(self.user, "stargazer")
|
||||
self.assertTrue(created)
|
||||
self.assertIsNotNone(brief)
|
||||
# Note Unlocks Post created on user
|
||||
post = Post.objects.get(owner=self.user, kind=Post.KIND_NOTE_UNLOCK)
|
||||
# Brief points at that Post + the appended Line
|
||||
self.assertEqual(brief.post_id, post.id)
|
||||
self.assertEqual(brief.kind, Brief.KIND_NOTE_UNLOCK)
|
||||
self.assertTrue(brief.is_unread)
|
||||
self.assertEqual(brief.owner, self.user)
|
||||
# The Brief's line is one of the Post's lines
|
||||
self.assertIn(brief.line, list(post.lines.all()))
|
||||
# Brief title matches Note display title
|
||||
self.assertEqual(brief.title, note.display_title)
|
||||
|
||||
def test_second_grant_same_slug_returns_no_brief(self):
|
||||
Note.grant_if_new(self.user, "stargazer")
|
||||
note, created, brief = Note.grant_if_new(self.user, "stargazer")
|
||||
self.assertFalse(created)
|
||||
self.assertIsNone(brief)
|
||||
# Still only one Brief / Line for the user (idempotent)
|
||||
self.assertEqual(
|
||||
Brief.objects.filter(owner=self.user, kind=Brief.KIND_NOTE_UNLOCK).count(), 1
|
||||
)
|
||||
|
||||
def test_two_different_grants_share_one_post(self):
|
||||
"""Per-category Post: stargazer + schizo unlocks both append Lines to
|
||||
the same Note Unlocks Post (one growing thread)."""
|
||||
Note.grant_if_new(self.user, "stargazer")
|
||||
Note.grant_if_new(self.user, "schizo")
|
||||
posts = Post.objects.filter(owner=self.user, kind=Post.KIND_NOTE_UNLOCK)
|
||||
self.assertEqual(posts.count(), 1, "Only one Note Unlocks Post per user")
|
||||
post = posts.first()
|
||||
# 2 Briefs, one per unlock
|
||||
self.assertEqual(Brief.objects.filter(post=post).count(), 2)
|
||||
# 3 distinct Lines on the Post: 1 header ("Look! — new Note unlocked")
|
||||
# + 2 per-event Lines (one per unlock)
|
||||
line_texts = list(post.lines.values_list("text", flat=True))
|
||||
self.assertEqual(len(set(line_texts)), 3)
|
||||
|
||||
def test_brief_line_text_includes_note_title(self):
|
||||
_, _, brief = Note.grant_if_new(self.user, "stargazer")
|
||||
self.assertIn("Stargazer", brief.line.text)
|
||||
|
||||
def test_post_kind_is_note_unlock_for_grant(self):
|
||||
Note.grant_if_new(self.user, "stargazer")
|
||||
post = Post.objects.get(owner=self.user, kind=Post.KIND_NOTE_UNLOCK)
|
||||
self.assertEqual(post.kind, Post.KIND_NOTE_UNLOCK)
|
||||
|
||||
|
||||
class PostKindFieldTest(TestCase):
|
||||
"""Post gains a `kind` enum so the per-category lookup (e.g. find the
|
||||
user's Note Unlocks Post) is deterministic; user-authored Posts default
|
||||
to KIND_USER_POST."""
|
||||
|
||||
def test_post_default_kind_is_user_post(self):
|
||||
u = User.objects.create(email="kind@test.io")
|
||||
p = Post.objects.create(owner=u)
|
||||
self.assertEqual(p.kind, Post.KIND_USER_POST)
|
||||
|
||||
def test_post_kind_choices_include_three_values(self):
|
||||
choices = dict(Post._meta.get_field("kind").choices)
|
||||
self.assertIn(Post.KIND_NOTE_UNLOCK, choices)
|
||||
self.assertIn(Post.KIND_USER_POST, choices)
|
||||
self.assertIn(Post.KIND_SHARE_INVITE, choices)
|
||||
Reference in New Issue
Block a user