Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
296 lines
13 KiB
JavaScript
296 lines
13 KiB
JavaScript
// ── NoteSpec.js ───────────────────────────────────────────────────────────────
|
|
//
|
|
// 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:
|
|
// Brief.showBanner(brief)
|
|
// brief = { id, kind, title, line_text, post_url, square_url, created_at }
|
|
// → inject .note-banner
|
|
// brief = null → no-op
|
|
//
|
|
// Brief.handleSaveResponse(data)
|
|
// data = { saved: true, brief: {...} } → delegates to showBanner
|
|
// data = { saved: true, brief: null } → no-op
|
|
//
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
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('Brief.showBanner', () => {
|
|
|
|
let fixture;
|
|
|
|
beforeEach(() => {
|
|
fixture = document.createElement('div');
|
|
fixture.id = 'note-fixture';
|
|
fixture.innerHTML = '<h2>Dash</h2>';
|
|
document.body.appendChild(fixture);
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.querySelectorAll('.note-banner').forEach(b => b.remove());
|
|
fixture.remove();
|
|
});
|
|
|
|
// ── T1 ── null → no banner ────────────────────────────────────────────────
|
|
|
|
it('T1: showBanner(null) does not inject a banner', () => {
|
|
Brief.showBanner(null);
|
|
expect(document.querySelector('.note-banner')).toBeNull();
|
|
});
|
|
|
|
// ── T2 ── brief present → banner in DOM ──────────────────────────────────
|
|
|
|
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 brief.title', () => {
|
|
Brief.showBanner(SAMPLE_BRIEF);
|
|
const el = document.querySelector('.note-banner__title');
|
|
expect(el).not.toBeNull();
|
|
expect(el.textContent).toContain('Stargazer');
|
|
});
|
|
|
|
// ── T4 ── description (line_text) ─────────────────────────────────────────
|
|
|
|
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('Stargazer, 02:00:00 AM');
|
|
});
|
|
|
|
// ── T5 ── timestamp ───────────────────────────────────────────────────────
|
|
|
|
it('T5: banner has a .note-banner__timestamp element', () => {
|
|
Brief.showBanner(SAMPLE_BRIEF);
|
|
expect(document.querySelector('.note-banner__timestamp')).not.toBeNull();
|
|
});
|
|
|
|
// ── T6 ── image area; for note_unlock kind it's a clickable square ──────
|
|
|
|
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', () => {
|
|
Brief.showBanner(SAMPLE_BRIEF);
|
|
expect(document.querySelector('.note-banner .btn.btn-cancel')).not.toBeNull();
|
|
});
|
|
|
|
// ── T8 ── FYI link points to brief.post_url (not hardcoded my-notes) ─────
|
|
|
|
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/post/abc/');
|
|
});
|
|
|
|
// ── T9 ── NVM dismissal ───────────────────────────────────────────────────
|
|
|
|
it('T9: clicking the NVM button removes the banner from the DOM', () => {
|
|
Brief.showBanner(SAMPLE_BRIEF);
|
|
document.querySelector('.note-banner .btn.btn-cancel').click();
|
|
expect(document.querySelector('.note-banner')).toBeNull();
|
|
});
|
|
|
|
// ── T10 ── placement: anchor preferred over h2 ───────────────────────────
|
|
|
|
it('T10a: banner is inserted as nextSibling of #id_brief_banner_anchor when present', () => {
|
|
const anchor = document.createElement('div');
|
|
anchor.id = 'id_brief_banner_anchor';
|
|
fixture.appendChild(anchor);
|
|
Brief.showBanner(SAMPLE_BRIEF);
|
|
expect(anchor.nextElementSibling.classList.contains('note-banner')).toBeTrue();
|
|
});
|
|
|
|
it('T10b: falls back to inserting after the first h2 when anchor is absent', () => {
|
|
Brief.showBanner(SAMPLE_BRIEF);
|
|
const h2 = fixture.querySelector('h2');
|
|
expect(h2.nextElementSibling.classList.contains('note-banner')).toBeTrue();
|
|
});
|
|
|
|
});
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
//
|
|
// Brief.showDuplicateBanner — the "already added" error variant. Distinct
|
|
// from showBanner because: the server doesn't persist a Brief row for these
|
|
// (transient, client-only), title is rendered as `@<username> is already
|
|
// present`, there's no date/square/post_url, and the FYI button toggles
|
|
// `.bud-duplicate-flash` onto a caller-supplied target element instead of
|
|
// navigating.
|
|
//
|
|
// API under test:
|
|
// Brief.showDuplicateBanner({ display_name, target_selector? })
|
|
// null / missing display_name → no banner
|
|
// target_selector matches an element → FYI click adds .bud-duplicate-flash
|
|
// target_selector missing or matches nothing → FYI just dismisses
|
|
//
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Brief.showDuplicateBanner', () => {
|
|
|
|
let fixture;
|
|
|
|
beforeEach(() => {
|
|
fixture = document.createElement('div');
|
|
fixture.id = 'dup-fixture';
|
|
fixture.innerHTML =
|
|
'<h2>Dash</h2>' +
|
|
'<ul id="bud-fixture-list">' +
|
|
'<li class="bud-entry" data-bud-id="42">' +
|
|
'<span class="bud-name">alice</span>' +
|
|
'</li>' +
|
|
'</ul>';
|
|
document.body.appendChild(fixture);
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.querySelectorAll('.note-banner').forEach(b => b.remove());
|
|
fixture.remove();
|
|
});
|
|
|
|
it('D1: missing display_name → no banner', () => {
|
|
Brief.showDuplicateBanner({});
|
|
expect(document.querySelector('.note-banner')).toBeNull();
|
|
});
|
|
|
|
it('D2: title reads "@<display_name> is already present"', () => {
|
|
Brief.showDuplicateBanner({ display_name: 'alice' });
|
|
const title = document.querySelector('.note-banner__title');
|
|
expect(title).not.toBeNull();
|
|
expect(title.textContent).toBe('@alice is already present');
|
|
});
|
|
|
|
it('D3: banner carries the .note-banner--duplicate modifier class', () => {
|
|
Brief.showDuplicateBanner({ display_name: 'alice' });
|
|
const b = document.querySelector('.note-banner');
|
|
expect(b).not.toBeNull();
|
|
expect(b.classList.contains('note-banner--duplicate')).toBeTrue();
|
|
});
|
|
|
|
it('D4: duplicate banner has no .note-banner__description or .note-banner__timestamp', () => {
|
|
Brief.showDuplicateBanner({ display_name: 'alice' });
|
|
expect(document.querySelector('.note-banner__description')).toBeNull();
|
|
expect(document.querySelector('.note-banner__timestamp')).toBeNull();
|
|
});
|
|
|
|
it('D5: FYI button is a <button>, not an <a> (no navigation)', () => {
|
|
Brief.showDuplicateBanner({ display_name: 'alice' });
|
|
const fyi = document.querySelector('.note-banner .note-banner__fyi');
|
|
expect(fyi).not.toBeNull();
|
|
expect(fyi.tagName.toLowerCase()).toBe('button');
|
|
});
|
|
|
|
it('D6: NVM dismisses without adding .bud-duplicate-flash anywhere', () => {
|
|
Brief.showDuplicateBanner({
|
|
display_name: 'alice',
|
|
target_selector: '.bud-entry[data-bud-id="42"] .bud-name',
|
|
});
|
|
document.querySelector('.note-banner__nvm').click();
|
|
expect(document.querySelector('.note-banner')).toBeNull();
|
|
const name = fixture.querySelector('.bud-name');
|
|
expect(name.classList.contains('bud-duplicate-flash')).toBeFalse();
|
|
});
|
|
|
|
it('D7: FYI dismisses AND adds .bud-duplicate-flash to the target element', () => {
|
|
Brief.showDuplicateBanner({
|
|
display_name: 'alice',
|
|
target_selector: '.bud-entry[data-bud-id="42"] .bud-name',
|
|
});
|
|
document.querySelector('.note-banner__fyi').click();
|
|
expect(document.querySelector('.note-banner')).toBeNull();
|
|
const name = fixture.querySelector('.bud-name');
|
|
expect(name.classList.contains('bud-duplicate-flash')).toBeTrue();
|
|
});
|
|
|
|
it('D7b: .bud-duplicate-flash auto-eases out — class is removed ~3s after FYI', () => {
|
|
jasmine.clock().install();
|
|
try {
|
|
Brief.showDuplicateBanner({
|
|
display_name: 'alice',
|
|
target_selector: '.bud-entry[data-bud-id="42"] .bud-name',
|
|
});
|
|
document.querySelector('.note-banner__fyi').click();
|
|
const name = fixture.querySelector('.bud-name');
|
|
// Immediately after FYI: flash is on (the visible peak state).
|
|
expect(name.classList.contains('bud-duplicate-flash')).toBeTrue();
|
|
// After the auto-dismiss window: flash is gone.
|
|
jasmine.clock().tick(3001);
|
|
expect(name.classList.contains('bud-duplicate-flash')).toBeFalse();
|
|
} finally {
|
|
jasmine.clock().uninstall();
|
|
}
|
|
});
|
|
|
|
it('D8: FYI dismisses cleanly when target_selector is missing', () => {
|
|
Brief.showDuplicateBanner({ display_name: 'alice' });
|
|
document.querySelector('.note-banner__fyi').click();
|
|
expect(document.querySelector('.note-banner')).toBeNull();
|
|
});
|
|
|
|
it('D9: FYI dismisses cleanly when target_selector matches nothing', () => {
|
|
Brief.showDuplicateBanner({
|
|
display_name: 'alice',
|
|
target_selector: '.gate-slot[data-user-id="does-not-exist"]',
|
|
});
|
|
document.querySelector('.note-banner__fyi').click();
|
|
expect(document.querySelector('.note-banner')).toBeNull();
|
|
});
|
|
|
|
it('D10: title escapes HTML in display_name', () => {
|
|
Brief.showDuplicateBanner({ display_name: '<script>x</script>' });
|
|
const title = document.querySelector('.note-banner__title');
|
|
expect(title.textContent).toBe('@<script>x</script> is already present');
|
|
expect(title.querySelector('script')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Brief.handleSaveResponse', () => {
|
|
|
|
afterEach(() => {
|
|
document.querySelectorAll('.note-banner').forEach(b => b.remove());
|
|
});
|
|
|
|
// ── T11 ── delegates when brief present ──────────────────────────────────
|
|
|
|
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 brief 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();
|
|
});
|
|
|
|
});
|