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:
@@ -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',
|
||||
title: 'Stargazer',
|
||||
description: 'You saved your first personal sky chart.',
|
||||
earned_at: '2026-04-22T02:00:00+00:00',
|
||||
const SAMPLE_BRIEF = {
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
kind: 'note_unlock',
|
||||
title: 'Stargazer',
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user