Files
python-tdd/src/static/tests/NoteSpec.js
Disco DeDisco f7fa250804
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
.bud-duplicate-flash: auto-ease-out 3s after FYI + palette swap — note.js's Brief.showDuplicateBanner FYI handler now setTimeout(() => target.classList.remove('bud-duplicate-flash'), 3000) after the .add(); the existing transition: color 600ms ease, text-shadow 600ms ease rule on the class already covered the ease-in (default → flash), so the same rule now also covers the ease-out (flash → default) when the class drops — net behaviour: tap FYI → flash peaks → flash visibly fades back to the default text styling over ~600ms after a 3s hold, instead of persisting til page refresh; palette keys swapped per user steer — color: var(--terUser); text-shadow: var(--ninUser)color: var(--ninUser); text-shadow: var(--terUser), so the highlight reads as a lighter handle w. a gold glow rather than a gold handle w. a light glow, matching the duplicate-guard spec the user re-aligned on; affects all three flash targets uniformly (.bud-entry .bud-name on /billboard/my-buds/, .post-recipient on post.html share-flow, .gate-slot.filled on the gatekeeper invite-flow) since they all flow through the same _bud.scss .bud-duplicate-flash selector + the same Brief.showDuplicateBanner JS handler; new Jasmine spec D7b in NoteSpec.js uses jasmine.clock().install() + clock().tick(3001) to fast-forward past the dismiss window + assert the class is gone (existing D7 still pins the immediate-after-FYI peak state); existing FTs (test_bill_my_buds.test_re_add_existing_bud_shows_already_present_brief… + test_core_bud_btn duplicate-guard FTs) still green because they assert immediately after the FYI click (well inside the 3s hold) — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 00:43:03 -04:00

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();
});
});