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:
18
src/apps/billboard/migrations/0003_post_kind.py
Normal file
18
src/apps/billboard/migrations/0003_post_kind.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0 on 2026-05-08 21:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billboard', '0002_brief'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='post',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('note_unlock', 'Note unlocks'), ('user_post', 'User post'), ('share_invite', 'Share invites')], default='user_post', max_length=32),
|
||||
),
|
||||
]
|
||||
@@ -6,6 +6,15 @@ from django.utils import timezone
|
||||
|
||||
|
||||
class Post(models.Model):
|
||||
KIND_NOTE_UNLOCK = "note_unlock"
|
||||
KIND_USER_POST = "user_post"
|
||||
KIND_SHARE_INVITE = "share_invite"
|
||||
KIND_CHOICES = [
|
||||
(KIND_NOTE_UNLOCK, "Note unlocks"),
|
||||
(KIND_USER_POST, "User post"),
|
||||
(KIND_SHARE_INVITE, "Share invites"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
owner = models.ForeignKey(
|
||||
"lyric.User",
|
||||
@@ -21,6 +30,15 @@ class Post(models.Model):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
# `kind` discriminates per-category Posts — e.g. Note.grant_if_new appends
|
||||
# to the user's single (owner=user, kind=NOTE_UNLOCK) Post; user-authored
|
||||
# composes default to KIND_USER_POST.
|
||||
kind = models.CharField(
|
||||
max_length=32,
|
||||
choices=KIND_CHOICES,
|
||||
default=KIND_USER_POST,
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.lines.first().text
|
||||
|
||||
@@ -1,27 +1,44 @@
|
||||
const Note = (() => {
|
||||
// Slide-down Brief banner — appears under the navbar h2 with a Gaussian-glass
|
||||
// background. Banner data comes from the Brief.to_banner_dict shape on the
|
||||
// server: {id, kind, title, line_text, post_url, square_url, created_at}.
|
||||
//
|
||||
// FYI button → brief.post_url (the post detail; that GET marks the Brief read).
|
||||
// .note-banner__image (the "square") → brief.square_url (kind-specific
|
||||
// shortcut; note-unlock briefs jump direct to /billboard/my-notes/).
|
||||
// NVM dismisses without marking read.
|
||||
//
|
||||
// `Note` is preserved as an alias for backwards-compat with any leftover
|
||||
// caller while the C3 sprint lands; new code should use `Brief.*`.
|
||||
|
||||
const Brief = (() => {
|
||||
'use strict';
|
||||
|
||||
function showBanner(note) {
|
||||
if (!note) return;
|
||||
function showBanner(brief) {
|
||||
if (!brief) return;
|
||||
|
||||
const earned = new Date(note.earned_at);
|
||||
const dateStr = earned.toLocaleDateString(undefined, {
|
||||
const created = new Date(brief.created_at);
|
||||
const dateStr = created.toLocaleDateString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
});
|
||||
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'note-banner';
|
||||
|
||||
const squareEl = brief.square_url
|
||||
? '<a href="' + _esc(brief.square_url) + '" class="note-banner__image"></a>'
|
||||
: '<div class="note-banner__image"></div>';
|
||||
|
||||
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) + '">' +
|
||||
'<p class="note-banner__title">' + _esc(brief.title) + '</p>' +
|
||||
'<p class="note-banner__description">' + _esc(brief.line_text) + '</p>' +
|
||||
'<time class="note-banner__timestamp" datetime="' + _esc(brief.created_at) + '">' +
|
||||
dateStr +
|
||||
'</time>' +
|
||||
'</div>' +
|
||||
'<div class="note-banner__image"></div>' +
|
||||
squareEl +
|
||||
'<button type="button" class="btn btn-cancel note-banner__nvm">NVM</button>' +
|
||||
'<a href="/billboard/my-notes/" class="btn btn-info note-banner__fyi">FYI</a>';
|
||||
'<a href="' + _esc(brief.post_url) + '" class="btn btn-info note-banner__fyi">FYI</a>';
|
||||
|
||||
banner.querySelector('.note-banner__nvm').addEventListener('click', function () {
|
||||
banner.remove();
|
||||
@@ -36,7 +53,7 @@ const Note = (() => {
|
||||
}
|
||||
|
||||
function handleSaveResponse(data) {
|
||||
showBanner(data && data.note);
|
||||
showBanner(data && data.brief);
|
||||
}
|
||||
|
||||
function _esc(str) {
|
||||
@@ -47,3 +64,6 @@ const Note = (() => {
|
||||
|
||||
return { showBanner: showBanner, handleSaveResponse: handleSaveResponse };
|
||||
})();
|
||||
|
||||
// Backwards-compat shim — to be removed once the codebase uniformly uses Brief.
|
||||
const Note = Brief;
|
||||
|
||||
@@ -272,23 +272,25 @@ class SkySaveNoteTest(TestCase):
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_first_save_with_chart_data_returns_stargazer_note(self):
|
||||
def test_first_save_with_chart_data_returns_stargazer_brief(self):
|
||||
data = self._post().json()
|
||||
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)
|
||||
self.assertIn("brief", data)
|
||||
brief = data["brief"]
|
||||
self.assertEqual(brief["kind"], "note_unlock")
|
||||
self.assertEqual(brief["title"], "Stargazer")
|
||||
self.assertIn("Stargazer", brief["line_text"])
|
||||
self.assertIn("/billboard/post/", brief["post_url"])
|
||||
self.assertEqual(brief["square_url"], "/billboard/my-notes/")
|
||||
self.assertIn("created_at", brief)
|
||||
|
||||
def test_first_save_creates_note_in_db(self):
|
||||
self._post()
|
||||
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 1)
|
||||
|
||||
def test_second_save_returns_null_note(self):
|
||||
def test_second_save_returns_null_brief(self):
|
||||
self._post()
|
||||
data = self._post().json()
|
||||
self.assertIsNone(data["note"])
|
||||
self.assertIsNone(data["brief"])
|
||||
|
||||
def test_second_save_does_not_create_duplicate_note(self):
|
||||
self._post()
|
||||
@@ -297,12 +299,12 @@ class SkySaveNoteTest(TestCase):
|
||||
|
||||
def test_save_with_empty_chart_data_does_not_grant_note(self):
|
||||
data = self._post(chart_data={}).json()
|
||||
self.assertIsNone(data["note"])
|
||||
self.assertIsNone(data["brief"])
|
||||
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0)
|
||||
|
||||
def test_save_with_null_chart_data_does_not_grant_note(self):
|
||||
data = self._post(chart_data=None).json()
|
||||
self.assertIsNone(data["note"])
|
||||
self.assertIsNone(data["brief"])
|
||||
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0)
|
||||
|
||||
|
||||
|
||||
@@ -362,18 +362,32 @@ def sky_save(request):
|
||||
'sky_birth_place', 'sky_birth_tz', 'sky_house_system', 'sky_chart_data',
|
||||
])
|
||||
|
||||
note_payload = None
|
||||
brief_payload = None
|
||||
if user.sky_chart_data:
|
||||
note, created = Note.grant_if_new(user, "stargazer")
|
||||
if created:
|
||||
note_payload = {
|
||||
"slug": note.slug,
|
||||
"title": "Stargazer",
|
||||
"description": "You saved your first personal sky chart.",
|
||||
"earned_at": note.earned_at.isoformat(),
|
||||
}
|
||||
note, created, brief = Note.grant_if_new(user, "stargazer")
|
||||
if created and brief is not None:
|
||||
brief_payload = _brief_to_banner_dict(brief)
|
||||
|
||||
return JsonResponse({"saved": True, "note": note_payload})
|
||||
return JsonResponse({"saved": True, "brief": brief_payload})
|
||||
|
||||
|
||||
def _brief_to_banner_dict(brief):
|
||||
"""Shape a Brief for the slide-down banner JS. NOTE_UNLOCK kind carries
|
||||
a `square_url` pointing at /billboard/my-notes/ so the thumbnail-square
|
||||
inside the banner jumps direct to the user's Note collection."""
|
||||
square_url = ""
|
||||
if brief.kind == "note_unlock":
|
||||
from django.urls import reverse
|
||||
square_url = reverse("billboard:my_notes")
|
||||
return {
|
||||
"id": str(brief.id),
|
||||
"kind": brief.kind,
|
||||
"title": brief.title,
|
||||
"line_text": brief.line.text if brief.line else "",
|
||||
"post_url": brief.post.get_absolute_url(),
|
||||
"square_url": square_url,
|
||||
"created_at": brief.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
|
||||
@@ -237,8 +237,41 @@ class Note(models.Model):
|
||||
|
||||
@classmethod
|
||||
def grant_if_new(cls, user, slug):
|
||||
"""Grants the Note if it doesn't already exist on the user; on a fresh
|
||||
grant ALSO appends a Line to the user's per-category "Note Unlocks"
|
||||
Post (creating the Post on first-ever unlock) and spawns a Brief that
|
||||
FKs the appended Line. Returns ``(note, created, brief)`` — brief is
|
||||
None on idempotent re-grants. Banner-side affordances (FYI navigation,
|
||||
my-notes square) ride on the Brief.kind=NOTE_UNLOCK discriminator."""
|
||||
from django.utils import timezone
|
||||
return cls.objects.get_or_create(
|
||||
|
||||
from apps.billboard.models import Brief, Line, Post
|
||||
|
||||
note, created = cls.objects.get_or_create(
|
||||
user=user, slug=slug,
|
||||
defaults={"earned_at": timezone.now()},
|
||||
)
|
||||
if not created:
|
||||
return note, created, None
|
||||
|
||||
post, _ = Post.objects.get_or_create(
|
||||
owner=user, kind=Post.KIND_NOTE_UNLOCK,
|
||||
)
|
||||
# Per-category header Line (becomes Post.name) — only added once on
|
||||
# first-ever unlock for this user.
|
||||
Line.objects.get_or_create(post=post, text="Look! — new Note unlocked")
|
||||
# Per-event Line — text dedupe is enforced by the unique_together on
|
||||
# (post, text), so two unlocks of the same slug at the same minute
|
||||
# would clash; the timestamp suffix carries the second of resolution.
|
||||
# %-I (lstrip-zero hour) is POSIX-only; %I gives "05:21:00 PM" — fine
|
||||
# on Windows + Linux, and the leading zero is acceptable in a Line.
|
||||
line_text = f"{note.display_title}, {note.earned_at:%I:%M:%S %p}"
|
||||
line = Line.objects.create(post=post, text=line_text)
|
||||
brief = Brief.objects.create(
|
||||
owner=user,
|
||||
post=post,
|
||||
line=line,
|
||||
kind=Brief.KIND_NOTE_UNLOCK,
|
||||
title=note.display_title,
|
||||
)
|
||||
return note, created, brief
|
||||
|
||||
@@ -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)
|
||||
@@ -150,16 +150,27 @@ class StargazerNoteFromDashboardTest(FunctionalTest):
|
||||
)
|
||||
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")
|
||||
# Per the Brief sprint, .note-banner__image is now a clickable <a> for
|
||||
# NOTE_UNLOCK kind — square goes to my_notes.html (the legacy FYI
|
||||
# behavior preserved on the square).
|
||||
square = banner.find_element(By.CSS_SELECTOR, ".note-banner__image")
|
||||
self.assertEqual(square.tag_name, "a")
|
||||
self.assertEqual(square.get_attribute("href").rstrip("/"),
|
||||
self.live_server_url + "/billboard/my-notes")
|
||||
banner.find_element(By.CSS_SELECTOR, ".btn.btn-cancel") # NVM
|
||||
fyi = banner.find_element(By.CSS_SELECTOR, ".btn.btn-info") # FYI
|
||||
|
||||
# FYI navigates to Note page
|
||||
# FYI now navigates to the underlying Brief's Post detail
|
||||
# (/billboard/post/<uuid>/) — the GET render is the mark-read contract.
|
||||
fyi.click()
|
||||
self.wait_for(
|
||||
lambda: self.assertRegex(self.browser.current_url, r"/billboard/my-notes")
|
||||
lambda: self.assertRegex(self.browser.current_url, r"/billboard/post/[0-9a-f-]+/")
|
||||
)
|
||||
|
||||
# Square (.note-banner__image) preserves the jump-direct-to-my-notes
|
||||
# behavior. Reload the dashboard and re-fire the banner via a stash.
|
||||
self.browser.get(self.live_server_url + "/billboard/my-notes/")
|
||||
|
||||
# Note page: one Stargazer item
|
||||
item = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-list .note-item")
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
// ── NoteSpec.js ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Unit specs for note.js — banner injection from sky/save response.
|
||||
// Unit specs for note.js (the slide-down Brief banner). The banner module is
|
||||
// exposed as `Brief` (with `Note` kept as an alias during the C3 sprint).
|
||||
//
|
||||
// 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:
|
||||
// Note.showBanner(note)
|
||||
// note = { slug, title, description, earned_at } → inject .note-banner
|
||||
// note = null → no-op
|
||||
// Brief.showBanner(brief)
|
||||
// brief = { id, kind, title, line_text, post_url, square_url, created_at }
|
||||
// → inject .note-banner
|
||||
// brief = null → no-op
|
||||
//
|
||||
// Note.handleSaveResponse(data)
|
||||
// data = { saved: true, note: {...} } → delegates to showBanner
|
||||
// data = { saved: true, note: null } → no-op
|
||||
// Brief.handleSaveResponse(data)
|
||||
// data = { saved: true, brief: {...} } → delegates to showBanner
|
||||
// data = { saved: true, brief: null } → no-op
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_NOTE = {
|
||||
slug: 'stargazer',
|
||||
const SAMPLE_BRIEF = {
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
kind: 'note_unlock',
|
||||
title: 'Stargazer',
|
||||
description: 'You saved your first personal sky chart.',
|
||||
earned_at: '2026-04-22T02:00:00+00:00',
|
||||
line_text: 'Stargazer, 02:00:00 AM',
|
||||
post_url: '/billboard/post/abc/',
|
||||
square_url: '/billboard/my-notes/',
|
||||
created_at: '2026-04-22T02:00:00+00:00',
|
||||
};
|
||||
|
||||
describe('Note.showBanner', () => {
|
||||
describe('Brief.showBanner', () => {
|
||||
|
||||
let fixture;
|
||||
|
||||
@@ -43,69 +48,72 @@ describe('Note.showBanner', () => {
|
||||
// ── T1 ── null → no banner ────────────────────────────────────────────────
|
||||
|
||||
it('T1: showBanner(null) does not inject a banner', () => {
|
||||
Note.showBanner(null);
|
||||
Brief.showBanner(null);
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
});
|
||||
|
||||
// ── T2 ── note present → banner in DOM ───────────────────────────────────
|
||||
// ── T2 ── brief present → banner in DOM ──────────────────────────────────
|
||||
|
||||
it('T2: showBanner(note) injects .note-banner into the document', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
it('T2: showBanner(brief) injects .note-banner into the document', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
expect(document.querySelector('.note-banner')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T3 ── title ───────────────────────────────────────────────────────────
|
||||
|
||||
it('T3: banner .note-banner__title contains note.title', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
it('T3: banner .note-banner__title contains brief.title', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const el = document.querySelector('.note-banner__title');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el.textContent).toContain('Stargazer');
|
||||
});
|
||||
|
||||
// ── T4 ── description ─────────────────────────────────────────────────────
|
||||
// ── T4 ── description (line_text) ─────────────────────────────────────────
|
||||
|
||||
it('T4: banner .note-banner__description contains note.description', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
it('T4: banner .note-banner__description carries brief.line_text', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const el = document.querySelector('.note-banner__description');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el.textContent).toContain('You saved your first personal sky chart.');
|
||||
expect(el.textContent).toContain('Stargazer, 02:00:00 AM');
|
||||
});
|
||||
|
||||
// ── T5 ── timestamp ───────────────────────────────────────────────────────
|
||||
|
||||
it('T5: banner has a .note-banner__timestamp element', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
expect(document.querySelector('.note-banner__timestamp')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T6 ── image area ──────────────────────────────────────────────────────
|
||||
// ── T6 ── image area; for note_unlock kind it's a clickable square ──────
|
||||
|
||||
it('T6: banner has a .note-banner__image element', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
expect(document.querySelector('.note-banner__image')).not.toBeNull();
|
||||
it('T6: banner .note-banner__image is a clickable <a> when brief.square_url is set', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const sq = document.querySelector('.note-banner__image');
|
||||
expect(sq).not.toBeNull();
|
||||
expect(sq.tagName.toLowerCase()).toBe('a');
|
||||
expect(sq.getAttribute('href')).toBe('/billboard/my-notes/');
|
||||
});
|
||||
|
||||
// ── T7 ── NVM button ──────────────────────────────────────────────────────
|
||||
|
||||
it('T7: banner has a .btn.btn-cancel NVM button', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
expect(document.querySelector('.note-banner .btn.btn-cancel')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T8 ── FYI link ────────────────────────────────────────────────────────
|
||||
// ── T8 ── FYI link points to brief.post_url (not hardcoded my-notes) ─────
|
||||
|
||||
it('T8: banner has a .btn.btn-info FYI link pointing to /billboard/my-notes/', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
it('T8: banner FYI .btn.btn-info link points to brief.post_url', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const fyi = document.querySelector('.note-banner .btn.btn-info');
|
||||
expect(fyi).not.toBeNull();
|
||||
expect(fyi.getAttribute('href')).toBe('/billboard/my-notes/');
|
||||
expect(fyi.getAttribute('href')).toBe('/billboard/post/abc/');
|
||||
});
|
||||
|
||||
// ── T9 ── NVM dismissal ───────────────────────────────────────────────────
|
||||
|
||||
it('T9: clicking the NVM button removes the banner from the DOM', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
document.querySelector('.note-banner .btn.btn-cancel').click();
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
});
|
||||
@@ -113,30 +121,30 @@ describe('Note.showBanner', () => {
|
||||
// ── T10 ── placement after h2 ─────────────────────────────────────────────
|
||||
|
||||
it('T10: banner is inserted immediately after the first h2 in the document', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const h2 = fixture.querySelector('h2');
|
||||
expect(h2.nextElementSibling.classList.contains('note-banner')).toBeTrue();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Note.handleSaveResponse', () => {
|
||||
describe('Brief.handleSaveResponse', () => {
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll('.note-banner').forEach(b => b.remove());
|
||||
});
|
||||
|
||||
// ── T11 ── delegates when note present ────────────────────────────────────
|
||||
// ── T11 ── delegates when brief present ──────────────────────────────────
|
||||
|
||||
it('T11: handleSaveResponse shows banner when data.note is present', () => {
|
||||
Note.handleSaveResponse({ saved: true, note: SAMPLE_NOTE });
|
||||
it('T11: handleSaveResponse shows banner when data.brief is present', () => {
|
||||
Brief.handleSaveResponse({ saved: true, brief: SAMPLE_BRIEF });
|
||||
expect(document.querySelector('.note-banner')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T12 ── no banner when note null ───────────────────────────────────────
|
||||
// ── T12 ── no banner when brief null ─────────────────────────────────────
|
||||
|
||||
it('T12: handleSaveResponse does not show banner when data.note is null', () => {
|
||||
Note.handleSaveResponse({ saved: true, note: null });
|
||||
it('T12: handleSaveResponse does not show banner when data.brief is null', () => {
|
||||
Brief.handleSaveResponse({ saved: true, brief: null });
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
// ── NoteSpec.js ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Unit specs for note.js — banner injection from sky/save response.
|
||||
// Unit specs for note.js (the slide-down Brief banner). The banner module is
|
||||
// exposed as `Brief` (with `Note` kept as an alias during the C3 sprint).
|
||||
//
|
||||
// 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:
|
||||
// Note.showBanner(note)
|
||||
// note = { slug, title, description, earned_at } → inject .note-banner
|
||||
// note = null → no-op
|
||||
// Brief.showBanner(brief)
|
||||
// brief = { id, kind, title, line_text, post_url, square_url, created_at }
|
||||
// → inject .note-banner
|
||||
// brief = null → no-op
|
||||
//
|
||||
// Note.handleSaveResponse(data)
|
||||
// data = { saved: true, note: {...} } → delegates to showBanner
|
||||
// data = { saved: true, note: null } → no-op
|
||||
// Brief.handleSaveResponse(data)
|
||||
// data = { saved: true, brief: {...} } → delegates to showBanner
|
||||
// data = { saved: true, brief: null } → no-op
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_NOTE = {
|
||||
slug: 'stargazer',
|
||||
const SAMPLE_BRIEF = {
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
kind: 'note_unlock',
|
||||
title: 'Stargazer',
|
||||
description: 'You saved your first personal sky chart.',
|
||||
earned_at: '2026-04-22T02:00:00+00:00',
|
||||
line_text: 'Stargazer, 02:00:00 AM',
|
||||
post_url: '/billboard/post/abc/',
|
||||
square_url: '/billboard/my-notes/',
|
||||
created_at: '2026-04-22T02:00:00+00:00',
|
||||
};
|
||||
|
||||
describe('Note.showBanner', () => {
|
||||
describe('Brief.showBanner', () => {
|
||||
|
||||
let fixture;
|
||||
|
||||
@@ -43,69 +48,72 @@ describe('Note.showBanner', () => {
|
||||
// ── T1 ── null → no banner ────────────────────────────────────────────────
|
||||
|
||||
it('T1: showBanner(null) does not inject a banner', () => {
|
||||
Note.showBanner(null);
|
||||
Brief.showBanner(null);
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
});
|
||||
|
||||
// ── T2 ── note present → banner in DOM ───────────────────────────────────
|
||||
// ── T2 ── brief present → banner in DOM ──────────────────────────────────
|
||||
|
||||
it('T2: showBanner(note) injects .note-banner into the document', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
it('T2: showBanner(brief) injects .note-banner into the document', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
expect(document.querySelector('.note-banner')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T3 ── title ───────────────────────────────────────────────────────────
|
||||
|
||||
it('T3: banner .note-banner__title contains note.title', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
it('T3: banner .note-banner__title contains brief.title', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const el = document.querySelector('.note-banner__title');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el.textContent).toContain('Stargazer');
|
||||
});
|
||||
|
||||
// ── T4 ── description ─────────────────────────────────────────────────────
|
||||
// ── T4 ── description (line_text) ─────────────────────────────────────────
|
||||
|
||||
it('T4: banner .note-banner__description contains note.description', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
it('T4: banner .note-banner__description carries brief.line_text', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const el = document.querySelector('.note-banner__description');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el.textContent).toContain('You saved your first personal sky chart.');
|
||||
expect(el.textContent).toContain('Stargazer, 02:00:00 AM');
|
||||
});
|
||||
|
||||
// ── T5 ── timestamp ───────────────────────────────────────────────────────
|
||||
|
||||
it('T5: banner has a .note-banner__timestamp element', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
expect(document.querySelector('.note-banner__timestamp')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T6 ── image area ──────────────────────────────────────────────────────
|
||||
// ── T6 ── image area; for note_unlock kind it's a clickable square ──────
|
||||
|
||||
it('T6: banner has a .note-banner__image element', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
expect(document.querySelector('.note-banner__image')).not.toBeNull();
|
||||
it('T6: banner .note-banner__image is a clickable <a> when brief.square_url is set', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const sq = document.querySelector('.note-banner__image');
|
||||
expect(sq).not.toBeNull();
|
||||
expect(sq.tagName.toLowerCase()).toBe('a');
|
||||
expect(sq.getAttribute('href')).toBe('/billboard/my-notes/');
|
||||
});
|
||||
|
||||
// ── T7 ── NVM button ──────────────────────────────────────────────────────
|
||||
|
||||
it('T7: banner has a .btn.btn-cancel NVM button', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
expect(document.querySelector('.note-banner .btn.btn-cancel')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T8 ── FYI link ────────────────────────────────────────────────────────
|
||||
// ── T8 ── FYI link points to brief.post_url (not hardcoded my-notes) ─────
|
||||
|
||||
it('T8: banner has a .btn.btn-info FYI link pointing to /billboard/my-notes/', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
it('T8: banner FYI .btn.btn-info link points to brief.post_url', () => {
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const fyi = document.querySelector('.note-banner .btn.btn-info');
|
||||
expect(fyi).not.toBeNull();
|
||||
expect(fyi.getAttribute('href')).toBe('/billboard/my-notes/');
|
||||
expect(fyi.getAttribute('href')).toBe('/billboard/post/abc/');
|
||||
});
|
||||
|
||||
// ── T9 ── NVM dismissal ───────────────────────────────────────────────────
|
||||
|
||||
it('T9: clicking the NVM button removes the banner from the DOM', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
document.querySelector('.note-banner .btn.btn-cancel').click();
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
});
|
||||
@@ -113,30 +121,30 @@ describe('Note.showBanner', () => {
|
||||
// ── T10 ── placement after h2 ─────────────────────────────────────────────
|
||||
|
||||
it('T10: banner is inserted immediately after the first h2 in the document', () => {
|
||||
Note.showBanner(SAMPLE_NOTE);
|
||||
Brief.showBanner(SAMPLE_BRIEF);
|
||||
const h2 = fixture.querySelector('h2');
|
||||
expect(h2.nextElementSibling.classList.contains('note-banner')).toBeTrue();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Note.handleSaveResponse', () => {
|
||||
describe('Brief.handleSaveResponse', () => {
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll('.note-banner').forEach(b => b.remove());
|
||||
});
|
||||
|
||||
// ── T11 ── delegates when note present ────────────────────────────────────
|
||||
// ── T11 ── delegates when brief present ──────────────────────────────────
|
||||
|
||||
it('T11: handleSaveResponse shows banner when data.note is present', () => {
|
||||
Note.handleSaveResponse({ saved: true, note: SAMPLE_NOTE });
|
||||
it('T11: handleSaveResponse shows banner when data.brief is present', () => {
|
||||
Brief.handleSaveResponse({ saved: true, brief: SAMPLE_BRIEF });
|
||||
expect(document.querySelector('.note-banner')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── T12 ── no banner when note null ───────────────────────────────────────
|
||||
// ── T12 ── no banner when brief null ─────────────────────────────────────
|
||||
|
||||
it('T12: handleSaveResponse does not show banner when data.note is null', () => {
|
||||
Note.handleSaveResponse({ saved: true, note: null });
|
||||
it('T12: handleSaveResponse does not show banner when data.brief is null', () => {
|
||||
Brief.handleSaveResponse({ saved: true, brief: null });
|
||||
expect(document.querySelector('.note-banner')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -367,7 +367,7 @@
|
||||
formWrap.style.display = 'none';
|
||||
svgEl.style.display = '';
|
||||
SkyWheel.preload().then(() => SkyWheel.draw(svgEl, _lastChartData));
|
||||
Note.handleSaveResponse(data);
|
||||
Brief.handleSaveResponse(data);
|
||||
})
|
||||
.catch(err => {
|
||||
setStatus(`Save failed: ${err.message}`, 'error');
|
||||
|
||||
@@ -315,7 +315,7 @@
|
||||
})
|
||||
.then(data => {
|
||||
setStatus('Sky saved!');
|
||||
Note.handleSaveResponse(data);
|
||||
Brief.handleSaveResponse(data);
|
||||
_activateSavedState();
|
||||
})
|
||||
.catch(err => {
|
||||
|
||||
Reference in New Issue
Block a user